@elliotsystems/eai 0.1.5 → 0.1.9

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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Keeps the Jev guardrail's per-request activation current with the team's
3
+ * REAL, live `Team.jev_policy_enabled` — instead of the one-shot snapshot
4
+ * `eai setup` bakes into `opencode.json`'s static `options.metadata.
5
+ * guardrails` (see elliot-launcher/lib/setup.js's own `elliotProvider`).
6
+ *
7
+ * ## The gap this closes (CLI session-recovery)
8
+ *
9
+ * Web Chat's equivalent bug (terminal/src/opencode/useOpencodeChat.ts) was:
10
+ * an admin's `jev_policy_enabled` change doesn't reach an already-running
11
+ * OpenCode instance until that instance is torn down and rebuilt — fixed on
12
+ * the Web Chat side by (a) checking team-level drift on every request
13
+ * (backend `_reconcile`) and (b) transparently starting a fresh session when
14
+ * the old one's instance is gone.
15
+ *
16
+ * NEITHER half of that fix has a real analogue for the EAI CLI, and this
17
+ * module exists because of that, not in spite of it:
18
+ *
19
+ * - There is no "OpenCode server instance" to replace. Web Chat's
20
+ * OpenCode runtimes are backend-hosted containers/subprocesses, keyed by
21
+ * team, with their own lifecycle (services/opencode/manager.py). The
22
+ * CLI's OpenCode process is the user's own local machine running the
23
+ * real upstream `opencode` binary (elliot-launcher/bin/eai just
24
+ * `spawn`s it with `stdio: "inherit"`) — nothing on Elliot's backend
25
+ * ever tears it down or replaces it. A "replaced instance" literally
26
+ * cannot happen to it.
27
+ * - `elliot-launcher`'s `eai` has zero visibility into the TUI's traffic
28
+ * once spawned (inherited stdio, a real full-screen terminal app) and
29
+ * the OpenCode plugin API (opencode/packages/plugin/src/index.ts) has NO
30
+ * hook that observes a chat response or error — `tool.execute.after`
31
+ * only covers TOOL calls, not the chat completion itself. So "detect
32
+ * this message got blocked, then resend it" is not implementable from
33
+ * either layer available to this integration: there is no hook to
34
+ * detect it from.
35
+ *
36
+ * What IS real, and what this fixes: the CLI's guardrail activation is a
37
+ * STATIC snapshot, taken once at `eai setup` and never touched again for the
38
+ * life of that file. An admin flipping the toggle after that leaves the CLI
39
+ * silently wrong in BOTH directions until the user re-runs `eai setup`:
40
+ * - turned ON after setup -> the CLI never guards anything (a real
41
+ * enforcement gap, not just a UX one);
42
+ * - turned OFF after setup -> the CLI keeps blocking every single prompt
43
+ * forever (the exact behavior reported: "for every prompts... fix it").
44
+ *
45
+ * `chat.params` (opencode/packages/plugin/src/index.ts) is the one hook that
46
+ * runs on EVERY outgoing request, before it is sent, with write access to
47
+ * the exact `options` object that becomes the request body (traced through
48
+ * session/llm/request.ts: `options = mergeOptions(base, model.options, ...)`
49
+ * is passed into `chat.params`'s `output.options`, and `model.options` is
50
+ * precisely where `eai setup` wrote `metadata.guardrails`). Using it here
51
+ * means the correct, CURRENT guardrail state is used on the message's one
52
+ * and only attempt — there is no failure to recover from, no retry, and
53
+ * therefore none of the duplicate-request/duplicate-tool-execution risk a
54
+ * retry-based design would carry.
55
+ *
56
+ * ## Fail-safe direction
57
+ *
58
+ * A live check that cannot complete (offline, backend down, expired token)
59
+ * returns `null` and changes NOTHING — the request goes out with whatever
60
+ * the static config already had, exactly as it did before this module
61
+ * existed. This can under-protect (an admin's brand-new enable is missed
62
+ * while offline) but never over-blocks and never throws, which is the
63
+ * right trade for a CLI tool that must keep working offline: this was never
64
+ * the enforcement boundary (LiteLLM's own guardrail is, and it fails CLOSED
65
+ * on its own uncertainty — see jev_decisions_logic.py's CONFIDENCE_THRESHOLD
66
+ * — because THAT failure is security-load-bearing). This module only
67
+ * decides which flag to send; a transport failure here is an availability
68
+ * question, not a security one.
69
+ */
70
+ import { readElliotConfig, type ElliotConfig } from "./elliot-auth.js"
71
+
72
+ export const JEV_GUARDRAIL_NAME = "jev-policy"
73
+
74
+ /**
75
+ * Jev audit (2026-10, G2): the POST-CALL counterpart to `JEV_GUARDRAIL_NAME`
76
+ * — must match `Team.jev_output_validation_enabled`'s own LiteLLM guardrail
77
+ * name (`deploy/litellm/config.yaml`'s `"jev-output-validation"` entry,
78
+ * `backend/app/services/jev_guardrail_names.py`'s `POST_CALL_GUARDRAIL_NAME`).
79
+ * Before this constant/the functions below existed, this file only ever
80
+ * live-synced `jev-policy` — an admin enabling or disabling
81
+ * `jev_output_validation_enabled` had ZERO effect on an already-`eai setup`
82
+ * CLI session until the user re-ran setup, the exact bug this whole module
83
+ * exists to prevent for the pre-call flag (see this file's own top docstring)
84
+ * but silently left unfixed for the post-call one.
85
+ */
86
+ export const OUTPUT_VALIDATION_GUARDRAIL_NAME = "jev-output-validation"
87
+
88
+ /** How long a live answer is trusted before the next request re-checks.
89
+ * Short enough that an admin's change reaches the CLI within one coffee
90
+ * break of it taking effect; long enough that a user typing several
91
+ * messages in a row does not turn every one into a network round trip. */
92
+ const CACHE_TTL_MS = 15_000
93
+
94
+ /** Per-request timeout for the live check itself. A hung connection must
95
+ * not add a user-visible stall to every single message. */
96
+ const CHECK_TIMEOUT_MS = 3_000
97
+
98
+ interface CacheEntry {
99
+ jevPolicyEnabled: boolean
100
+ /** Jev audit (2026-10, G2). */
101
+ jevOutputValidationEnabled: boolean
102
+ teamId: string
103
+ expiresAt: number
104
+ }
105
+
106
+ let cache: CacheEntry | null = null
107
+
108
+ /** Test-only: forget whatever the last live check learned, so a test does
109
+ * not inherit state another test left behind (module-level cache is
110
+ * otherwise invisible from outside this file). */
111
+ export function resetGuardrailSyncCache(): void {
112
+ cache = null
113
+ }
114
+
115
+ export interface LiveCheckDeps {
116
+ /** Overridable for tests — never the real network in a unit test. */
117
+ fetchImpl?: typeof fetch
118
+ now?: () => number
119
+ /** Overridable so a test does not have to write a real file to disk for
120
+ * every case; defaults to the real `~/.elliot/config.json` reader. */
121
+ readConfig?: () => ElliotConfig | null
122
+ /**
123
+ * The team THIS OUTGOING REQUEST is actually scoped to — the ground
124
+ * truth already baked into `options.headers["x-litellm-spend-logs-
125
+ * metadata"]` by `teamconfig.py`'s own `_litellm_identity_headers` at
126
+ * provisioning time (see `extractInstanceTeamId` below, which the
127
+ * `chat.params` hook uses to derive this). NOT read from `~/.elliot/
128
+ * config.json` — that file is per-MACHINE, written once by `eai setup`
129
+ * for one specific person/team/backend.
130
+ *
131
+ * Required to trust the live check at all: a backend-hosted Web Chat
132
+ * OpenCode runtime, in this project's local-dev mode (`OPENCODE_
133
+ * RUNTIME_SUBPROCESS` — same user, same filesystem, no isolation), can
134
+ * end up running under a real developer's OWN `eai setup` login for a
135
+ * completely different team, tenant, or even backend (e.g. staging)
136
+ * than the one THIS request belongs to. Live-confirmed this session: a
137
+ * local "Test" team's guardrail toggle was silently overridden by a
138
+ * staging account's config for an unrelated team, because nothing here
139
+ * had ever checked that the two team ids agreed before trusting the
140
+ * live answer.
141
+ *
142
+ * `undefined` (the default) skips this check entirely — an existing
143
+ * caller with no way to know its own instance team id (there isn't one
144
+ * for the real EAI CLI, which never shares a filesystem with anyone
145
+ * else's session) keeps working exactly as before. Only an EXPLICIT,
146
+ * KNOWN mismatch (both ids present and different) suppresses the live
147
+ * check; this narrows an unsafe case, it does not add a new
148
+ * requirement for callers that already work correctly.
149
+ */
150
+ instanceTeamId?: string | null
151
+ }
152
+
153
+ /**
154
+ * Both guardrail toggles' current, live values from the backend — fetched
155
+ * and cached TOGETHER from the one `GET /teams/{id}` call that already
156
+ * returns both `jev_policy_enabled` and `jev_output_validation_enabled`
157
+ * (Jev audit, 2026-10, G2: previously only the pre-call flag was ever
158
+ * fetched/cached here, so the post-call one had no live-sync mechanism at
159
+ * all). Returns `null` under the exact same conditions `liveJevPolicyEnabled`
160
+ * always has (see its own docstring, preserved below) — no team context, an
161
+ * instance/request team-id mismatch, or a failed check with no usable cache.
162
+ */
163
+ async function liveJevGuardrailFlags(
164
+ deps: LiveCheckDeps = {},
165
+ ): Promise<{ jevPolicyEnabled: boolean; jevOutputValidationEnabled: boolean } | null> {
166
+ const now = deps.now ?? Date.now
167
+ const fetchImpl = deps.fetchImpl ?? fetch
168
+ const readConfig = deps.readConfig ?? readElliotConfig
169
+ const config = readConfig()
170
+
171
+ // No team context at all -- a tenant-wide (owner/admin) setup, or a
172
+ // config predating team scoping. Nothing team-level to check live;
173
+ // genuinely unknown rather than "disabled."
174
+ if (!config?.backend_url || !config.tenant_id || !config.team_id || !config.jwt_token) {
175
+ return null
176
+ }
177
+
178
+ // The local machine's own login config is for a DIFFERENT team than the
179
+ // one this specific request is scoped to -- trusting it would silently
180
+ // apply (or strip) a guardrail based on someone else's team/tenant/
181
+ // backend. Treat exactly like every other "cannot determine" case above
182
+ // rather than guessing. Only fires when BOTH ids are actually known;
183
+ // `undefined`/`null` here (the default for every existing caller) never
184
+ // triggers it.
185
+ if (
186
+ deps.instanceTeamId !== undefined &&
187
+ deps.instanceTeamId !== null &&
188
+ deps.instanceTeamId !== config.team_id
189
+ ) {
190
+ return null
191
+ }
192
+
193
+ if (cache && cache.teamId === config.team_id && now() < cache.expiresAt) {
194
+ return { jevPolicyEnabled: cache.jevPolicyEnabled, jevOutputValidationEnabled: cache.jevOutputValidationEnabled }
195
+ }
196
+
197
+ const controller = new AbortController()
198
+ const timer = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS)
199
+ try {
200
+ const base = config.backend_url.replace(/\/+$/, "")
201
+ const res = await fetchImpl(`${base}/teams/${encodeURIComponent(config.team_id)}`, {
202
+ headers: { Authorization: `Bearer ${config.jwt_token}` },
203
+ signal: controller.signal,
204
+ })
205
+ if (!res.ok) throw new Error(`backend returned ${res.status}`)
206
+ const body: unknown = await res.json()
207
+ const record = body && typeof body === "object" ? (body as Record<string, unknown>) : {}
208
+ const jevPolicyEnabled = Boolean(record.jev_policy_enabled)
209
+ const jevOutputValidationEnabled = Boolean(record.jev_output_validation_enabled)
210
+ cache = {
211
+ jevPolicyEnabled,
212
+ jevOutputValidationEnabled,
213
+ teamId: config.team_id,
214
+ expiresAt: now() + CACHE_TTL_MS,
215
+ }
216
+ return { jevPolicyEnabled, jevOutputValidationEnabled }
217
+ } catch {
218
+ // Offline, backend unreachable, expired token, or a malformed response.
219
+ // Never throw into the chat.params hook over this -- a CLI message must
220
+ // still be sendable without a network round trip succeeding. Fall back
221
+ // to a still-fresh cached value for this same team if one exists;
222
+ // otherwise genuinely unknown.
223
+ return cache && cache.teamId === config.team_id && now() < cache.expiresAt
224
+ ? { jevPolicyEnabled: cache.jevPolicyEnabled, jevOutputValidationEnabled: cache.jevOutputValidationEnabled }
225
+ : null
226
+ } finally {
227
+ clearTimeout(timer)
228
+ }
229
+ }
230
+
231
+ /**
232
+ * The team's current `jev_policy_enabled`, live from the backend — or
233
+ * `null` when it cannot be determined (no team context, not logged in, the
234
+ * check itself failed, or the local config belongs to a DIFFERENT team than
235
+ * this request — see `instanceTeamId`), which callers must read as "use
236
+ * whatever is already configured," never as "disabled."
237
+ */
238
+ export async function liveJevPolicyEnabled(deps: LiveCheckDeps = {}): Promise<boolean | null> {
239
+ const flags = await liveJevGuardrailFlags(deps)
240
+ return flags ? flags.jevPolicyEnabled : null
241
+ }
242
+
243
+ /**
244
+ * The POST-CALL counterpart to `liveJevPolicyEnabled` — the team's current
245
+ * `jev_output_validation_enabled`, live from the backend, with the exact
246
+ * same `null`-means-"leave it alone" contract (Jev audit, 2026-10, G2).
247
+ * Shares `liveJevPolicyEnabled`'s own cache/fetch under the hood (one
248
+ * `GET /teams/{id}` call answers both), so calling both on the same request
249
+ * costs at most one network round trip, not two.
250
+ */
251
+ export async function liveJevOutputValidationEnabled(deps: LiveCheckDeps = {}): Promise<boolean | null> {
252
+ const flags = await liveJevGuardrailFlags(deps)
253
+ return flags ? flags.jevOutputValidationEnabled : null
254
+ }
255
+
256
+ /**
257
+ * Applies a live guardrail decision to the outgoing request's `options`,
258
+ * mutating `metadata.guardrails` to match exactly — adding `guardrailName`
259
+ * when live-enabled, removing it (even if the STATIC config baked it in at
260
+ * `eai setup` time) when live-disabled. A `null` decision (see
261
+ * `liveJevPolicyEnabled`'s own docstring) changes nothing, leaving whatever
262
+ * the static config/an earlier plugin already set untouched.
263
+ *
264
+ * `guardrailName` defaults to `JEV_GUARDRAIL_NAME` (pre-call) so every
265
+ * existing 2-argument call site is unaffected; pass
266
+ * `OUTPUT_VALIDATION_GUARDRAIL_NAME` for the post-call decision (Jev audit,
267
+ * 2026-10, G2). Two calls targeting different names compose safely — each
268
+ * only ever adds/removes its OWN name from the shared `guardrails` array,
269
+ * never touching the other's entry, so calling this once for each flag (as
270
+ * `index.ts`'s `chat.params` hook now does) correctly reflects both toggles
271
+ * independently on the same request.
272
+ *
273
+ * Pure and synchronous on purpose: the async live-check already happened by
274
+ * the time this runs, so applying its result cannot itself introduce a
275
+ * second network call, a retry, or any other source of a duplicate request.
276
+ */
277
+ export function applyGuardrailDecision(
278
+ options: Record<string, unknown>,
279
+ decision: boolean | null,
280
+ guardrailName: string = JEV_GUARDRAIL_NAME,
281
+ ): void {
282
+ if (decision === null) return
283
+
284
+ const metadata =
285
+ options.metadata && typeof options.metadata === "object" && !Array.isArray(options.metadata)
286
+ ? (options.metadata as Record<string, unknown>)
287
+ : (options.metadata = {} as Record<string, unknown>)
288
+
289
+ const existing = Array.isArray(metadata.guardrails)
290
+ ? (metadata.guardrails as unknown[]).filter((g): g is string => typeof g === "string")
291
+ : []
292
+ const withoutThisName = existing.filter((g) => g !== guardrailName)
293
+
294
+ metadata.guardrails = decision ? [...withoutThisName, guardrailName] : withoutThisName
295
+ }
296
+
297
+ /**
298
+ * The team id THIS specific outgoing request is scoped to, read from the
299
+ * SAME static header `teamconfig.py`'s `_litellm_identity_headers` already
300
+ * baked into `options.headers["x-litellm-spend-logs-metadata"]` at
301
+ * provisioning time — e.g. `{"tenant_id":"...","team_id":"...",
302
+ * "source":"opencode"}`. This is ground truth about the request regardless
303
+ * of which real person's OS login happens to be running the process, which
304
+ * is exactly why it is the right thing to compare `~/.elliot/config.json`'s
305
+ * own `team_id` against (see `LiveCheckDeps.instanceTeamId`'s own
306
+ * docstring).
307
+ *
308
+ * Returns `undefined` (not `null`) for "could not determine" — `undefined`
309
+ * is `LiveCheckDeps.instanceTeamId`'s own "skip this check" value, so a
310
+ * request shape this function does not recognize degrades to the EXACT
311
+ * pre-existing behavior (trust whatever `~/.elliot/config.json` says) rather
312
+ * than a new, stricter failure mode nobody asked for.
313
+ */
314
+ export function extractInstanceTeamId(options: Record<string, unknown> | null | undefined): string | undefined {
315
+ const headers = options?.["headers"]
316
+ if (!headers || typeof headers !== "object") return undefined
317
+ const raw = (headers as Record<string, unknown>)["x-litellm-spend-logs-metadata"]
318
+ if (typeof raw !== "string") return undefined
319
+ try {
320
+ const parsed: unknown = JSON.parse(raw)
321
+ const teamId =
322
+ parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>)["team_id"] : undefined
323
+ return typeof teamId === "string" && teamId ? teamId : undefined
324
+ } catch {
325
+ // Malformed/unexpected header shape -- "could not determine" (see this
326
+ // function's own docstring), never a thrown error over a header this
327
+ // module merely reads and does not own the shape of.
328
+ return undefined
329
+ }
330
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Per-message LiteLLM `end_user` attribution for a TEAM-shared runtime.
3
+ *
4
+ * A team instance's LiteLLM provider options carry exactly ONE static
5
+ * `x-litellm-end-user-id` header (`teamconfig.py::_litellm_identity_headers`),
6
+ * baked in once at config-generation time as the shared runtime's own
7
+ * instance id — correct for "which runtime", never "which person", because
8
+ * every authorized team member sends messages through that same instance.
9
+ * Confirmed live: two different people chatting through the same team
10
+ * runtime produced identical `end_user` values in LiteLLM's own spend logs.
11
+ *
12
+ * This corrects it per REQUEST via OpenCode's own `chat.headers` hook, which
13
+ * fires once per chat turn with that turn's live session id — late enough to
14
+ * win: `session/llm/request.ts` spreads this hook's returned headers LAST,
15
+ * after the static `options.headers` from `opencode.json`. The real
16
+ * sender's id is read back from that session's own metadata, stamped there
17
+ * by the Elliot backend right after creating the session
18
+ * (`opencode_chat.py::create_session`, `PATCH /session/:id`) — never
19
+ * invented here, never derived from the message content.
20
+ *
21
+ * A session with no such metadata — every session that predates this
22
+ * feature, a generic/personal instance (which already attributes correctly
23
+ * via its own static `owner_user_id` header and has no need of this), or a
24
+ * lookup that fails for any reason — is left alone: the static header keeps
25
+ * doing its job, exactly as before this hook existed.
26
+ */
27
+
28
+ /** The key the backend stamps on a session's own metadata. Mirrors the
29
+ * literal `opencode_chat.py::create_session` writes — not independently
30
+ * configurable, since a drift here would silently stop the handoff. */
31
+ const USER_ID_METADATA_KEY = "elliot_user_id"
32
+
33
+ /** The backend always stamps a real UUID (`User.id`). Anything else reaching
34
+ * here — a typo, a future metadata shape, a stale/foreign value — is not
35
+ * trustworthy as an identity and is left unresolved rather than forwarded
36
+ * verbatim to LiteLLM. */
37
+ const LOOKS_LIKE_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
38
+
39
+ /** Structural mirror of the one SDK method this module calls — the host's
40
+ * real client has many more, declared here only to the extent used (same
41
+ * reasoning `index.ts`'s own `PluginInput` mirror gives). */
42
+ export interface SessionClient {
43
+ session?: {
44
+ get?: (input: { path: { id: string } }) => Promise<unknown>
45
+ }
46
+ }
47
+
48
+ /** Pulls the session object out of whatever envelope the client returns —
49
+ * tolerant of an openapi-fetch-style `{data, error}` wrapper or a bare
50
+ * object, since both have been seen across this SDK's own generated
51
+ * methods. */
52
+ function unwrapSession(result: unknown): Record<string, unknown> | undefined {
53
+ if (!result || typeof result !== "object") return undefined
54
+ const withData = (result as { data?: unknown }).data
55
+ if (withData && typeof withData === "object") return withData as Record<string, unknown>
56
+ return result as Record<string, unknown>
57
+ }
58
+
59
+ /**
60
+ * The real sender's user id for `sessionID`, or `undefined` when it cannot
61
+ * be determined — never throws, never fabricates.
62
+ */
63
+ export async function resolveEndUserId(
64
+ client: SessionClient | undefined,
65
+ sessionID: string,
66
+ ): Promise<string | undefined> {
67
+ if (!client?.session?.get || !sessionID) return undefined
68
+ try {
69
+ const result = await client.session.get({ path: { id: sessionID } })
70
+ const session = unwrapSession(result)
71
+ const metadata = session?.["metadata"]
72
+ if (!metadata || typeof metadata !== "object") return undefined
73
+ const value = (metadata as Record<string, unknown>)[USER_ID_METADATA_KEY]
74
+ if (typeof value !== "string") return undefined
75
+ const trimmed = value.trim()
76
+ return LOOKS_LIKE_UUID.test(trimmed) ? trimmed : undefined
77
+ } catch {
78
+ // Best-effort only. An identity lookup must never break a chat turn.
79
+ return undefined
80
+ }
81
+ }
@@ -23,6 +23,16 @@ import { isElliotKnowledgeTool, parseOptions, shellVerdict } from "./policy.js"
23
23
  import { configHook } from "./proxy-provider.js"
24
24
  import { formatScope, scopeForWorktree } from "./scope.js"
25
25
  import { applyElliotIdentity } from "./identity.js"
26
+ import { resolveEndUserId, type SessionClient } from "./identity-headers.js"
27
+ import {
28
+ applyGuardrailDecision,
29
+ extractInstanceTeamId,
30
+ liveJevOutputValidationEnabled,
31
+ liveJevPolicyEnabled,
32
+ OUTPUT_VALIDATION_GUARDRAIL_NAME,
33
+ } from "./guardrail-sync.js"
34
+ import { stampSessionAffinity } from "./session-affinity.js"
35
+ import { stripBlockedTurns } from "./blocked-turns.js"
26
36
 
27
37
  // Structural mirrors of the host types (packages/plugin/src/index.ts @
28
38
  // v1.18.19). Declared locally so this package type-checks and unit-tests
@@ -37,6 +47,7 @@ export interface PluginInput {
37
47
  auth?: {
38
48
  set: (input: { path: { id: string }; body: Record<string, unknown> }) => Promise<unknown>
39
49
  }
50
+ session?: SessionClient["session"]
40
51
  [k: string]: unknown
41
52
  }
42
53
  serverUrl?: URL
@@ -70,6 +81,43 @@ export interface Hooks {
70
81
  input: { sessionID?: string; model: unknown },
71
82
  output: { system: string[] },
72
83
  ) => Promise<void>
84
+ /**
85
+ * Runs right before the session's message history is converted to the
86
+ * provider's wire format (session/prompt.ts, immediately before
87
+ * `MessageV2.toModelMessagesEffect`) — the one hook that sees, and can
88
+ * edit, exactly what the MODEL is about to be shown as this conversation's
89
+ * history. `output.messages` is the SAME array the caller goes on to use
90
+ * (session/plugin/index.ts's `trigger` never reassigns it), so a hook
91
+ * must mutate it in place; see blocked-turns.ts's own `stripBlockedTurns`.
92
+ */
93
+ "experimental.chat.messages.transform"?: (
94
+ input: Record<string, never>,
95
+ output: { messages: Array<{ info?: Record<string, unknown>; parts?: unknown[] }> },
96
+ ) => Promise<void>
97
+ "chat.headers"?: (
98
+ input: { sessionID: string; agent: unknown; model: unknown; provider: unknown; message: unknown },
99
+ output: { headers: Record<string, string> },
100
+ ) => Promise<void>
101
+ /**
102
+ * Runs on every outgoing request, before it is sent — the one hook with
103
+ * write access to `options`, the object `eai setup`'s static `metadata.
104
+ * guardrails` lands in (opencode/packages/opencode/src/session/llm/
105
+ * request.ts: `options = mergeOptions(base, model.options, ...)` is what
106
+ * this hook receives as `output.options`). See guardrail-sync.ts for why
107
+ * this is the CLI's real session-recovery mechanism: there is no response/
108
+ * error hook to retry FROM, so the fix is making the one attempt correct
109
+ * instead.
110
+ */
111
+ "chat.params"?: (
112
+ input: { sessionID: string; agent: unknown; model: unknown; provider: unknown; message: unknown },
113
+ output: {
114
+ temperature?: number
115
+ topP?: number
116
+ topK?: number
117
+ maxOutputTokens?: number
118
+ options: Record<string, unknown>
119
+ },
120
+ ) => Promise<void>
73
121
  config?: (input: any) => Promise<void>
74
122
  auth?: AuthHook
75
123
  dispose?: () => Promise<void>
@@ -157,6 +205,73 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
157
205
  applyElliotIdentity(output.system)
158
206
  },
159
207
 
208
+ /**
209
+ * Keeps a Jev-blocked prompt from leaking into the MODEL's own view of
210
+ * this conversation's history on every LATER turn (see blocked-turns.ts's
211
+ * own module docstring for the full "why" — a benign follow-up was
212
+ * getting answered alongside an unprompted refusal about the earlier,
213
+ * never-actually-sent request). Purely a context-shaping step: the
214
+ * stored session history (what the UI's own transcript reads) is
215
+ * untouched, only what gets sent to the model next.
216
+ */
217
+ "experimental.chat.messages.transform": async (_hookInput, output) => {
218
+ const filtered = stripBlockedTurns(output.messages)
219
+ if (filtered === output.messages) return
220
+ output.messages.length = 0
221
+ output.messages.push(...filtered)
222
+ },
223
+
224
+ /**
225
+ * Per-message LiteLLM `end_user` override for a TEAM-shared runtime. See
226
+ * ./identity-headers.ts for the full rationale. A no-op (static header
227
+ * untouched) when the session carries no stamped identity — a generic
228
+ * instance, a pre-existing session, or a client that doesn't expose
229
+ * `session.get` at all.
230
+ */
231
+ "chat.headers": async (hookInput, output) => {
232
+ const userId = await resolveEndUserId(input.client as SessionClient | undefined, hookInput.sessionID)
233
+ if (userId) output.headers["x-litellm-end-user-id"] = userId
234
+ },
235
+
236
+ /**
237
+ * The CLI's Jev guardrail session-recovery fix (see guardrail-sync.ts's
238
+ * own module docstring for the full "why this hook, not a retry" case).
239
+ * A `null` live decision (no team context, or the check itself failed —
240
+ * offline, backend down) changes nothing: whatever `eai setup` baked
241
+ * into this model's static `options.metadata.guardrails` is left
242
+ * exactly as it was, so this can only ever make a stale CLI config
243
+ * MORE correct, never less available.
244
+ *
245
+ * Also stamps `metadata.session_id` (see session-affinity.ts) so
246
+ * LiteLLM's `session_affinity` pre-call check can pin every turn of
247
+ * this session to the same upstream deployment — the mechanism that
248
+ * keeps DeepSeek's implicit prefix cache warm across a long agent
249
+ * session (deploy/litellm/config.yaml, `optional_pre_call_checks`).
250
+ *
251
+ * `instanceTeamId` (see guardrail-sync.ts's own docstring on
252
+ * `LiveCheckDeps`) is extracted from THIS request's own `options`
253
+ * rather than trusted from `~/.elliot/config.json` — on a shared-
254
+ * filesystem local-dev backend runtime, those two can disagree (a real
255
+ * person's own CLI login vs. the team this Web Chat request actually
256
+ * belongs to), and the live check must not apply one team's setting to
257
+ * another's request.
258
+ *
259
+ * Jev audit (2026-10, G2): both the pre-call (`jev-policy`) AND
260
+ * post-call (`jev-output-validation`) toggles are now live-synced here
261
+ * — previously only the pre-call flag was. `liveJevPolicyEnabled`/
262
+ * `liveJevOutputValidationEnabled` share one cached `GET /teams/{id}`
263
+ * fetch under the hood (see guardrail-sync.ts), so checking both here
264
+ * costs at most one network round trip, not two.
265
+ */
266
+ "chat.params": async (hookInput, output) => {
267
+ stampSessionAffinity(output.options, hookInput.sessionID)
268
+ const instanceTeamId = extractInstanceTeamId(output.options)
269
+ const preCallDecision = await liveJevPolicyEnabled({ instanceTeamId })
270
+ applyGuardrailDecision(output.options, preCallDecision)
271
+ const outputValidationDecision = await liveJevOutputValidationEnabled({ instanceTeamId })
272
+ applyGuardrailDecision(output.options, outputValidationDecision, OUTPUT_VALIDATION_GUARDRAIL_NAME)
273
+ },
274
+
160
275
  "permission.ask": async (permInput, output) => {
161
276
  if (permInput.type !== "bash") return
162
277
  const command = permInput.metadata?.["command"]
@@ -168,9 +283,9 @@ export const ElliotPlugin: Plugin = async (input, rawOptions) => {
168
283
  },
169
284
 
170
285
  /**
171
- * Registers the `elliot` provider (proxy baseURL) and defaults the
172
- * model to it — no-op when the user isn't logged into Elliot. See
173
- * `proxy-provider.ts`.
286
+ * Registers the `elliot` provider (LiteLLM gateway baseURL + static
287
+ * gateway key) and defaults the model to it — no-op when the user isn't
288
+ * logged into Elliot. See `proxy-provider.ts`.
174
289
  */
175
290
  config: async (c) => {
176
291
  configHook(c)
@@ -24,6 +24,10 @@ export function sanitizeKey(value: string): string {
24
24
  /** The backend tool behind every Elliot knowledge lookup. */
25
25
  export const KNOWLEDGE_TOOL = "knowledge_search"
26
26
 
27
+ /** The backend tool behind "my own real EAI Gateway spend" (`/usage`, and the
28
+ * sidebar's real-spend section -- see `tui/usage.ts`). */
29
+ export const USAGE_TOOL = "get_eai_usage"
30
+
27
31
  /** The agent-visible key for `KNOWLEDGE_TOOL` on a given MCP server.
28
32
  *
29
33
  * Still the PRE-GATEWAY spelling, and still correct for a runtime wired
@@ -21,9 +21,38 @@ export const ELLIOT_PROVIDER_ID = "elliot"
21
21
  export const ELLIOT_GENERAL_MODEL_ID = "elliot-general"
22
22
  export const ELLIOT_CODER_MODEL_ID = "elliot-coder"
23
23
 
24
+ // `limit`/`cost` are what the OpenCode TUI's own sidebar ("Context ... %
25
+ // used ... spent") computes the context-window percentage and per-session
26
+ // dollar cost from (opencode/packages/tui/src/feature-plugins/sidebar/
27
+ // context.tsx) — without them it always shows "0% used"/"$0.00 spent"
28
+ // regardless of real usage, since there is nothing to divide/multiply by.
29
+ //
30
+ // Neither LiteLLM nor its own cost map has this data for either model
31
+ // (confirmed live: `GET /model/info` on the local gateway returns
32
+ // `max_tokens`/`max_input_tokens`/`input_cost_per_token`/
33
+ // `output_cost_per_token` as null/0 for both elliot-general and
34
+ // elliot-coder) — both route to the same OpenRouter deployment,
35
+ // `openrouter/deepseek/deepseek-v4-flash-0731` (deploy/litellm/
36
+ // config.yaml), so the figures below are OpenRouter's own published model
37
+ // data for that exact slug (`GET https://openrouter.ai/api/v1/models`,
38
+ // confirmed live 2026-10-14: context_length 1048576, top_provider.
39
+ // max_completion_tokens 943718, pricing.prompt $0.000000016/token,
40
+ // pricing.completion $0.00000064/token) — not a guess, and not a figure
41
+ // for a different/similar-sounding model. `cost` is $-per-MILLION-tokens,
42
+ // matching this schema's own convention elsewhere (e.g. opencode/packages/
43
+ // opencode/test/provider/transform.test.ts's real-model fixtures), so the
44
+ // per-token prices above are multiplied by 1e6.
24
45
  const ALL_MODELS = {
25
- [ELLIOT_GENERAL_MODEL_ID]: { name: "Elliot General" },
26
- [ELLIOT_CODER_MODEL_ID]: { name: "Elliot Coder" },
46
+ [ELLIOT_GENERAL_MODEL_ID]: {
47
+ name: "EAI General",
48
+ limit: { context: 1_048_576, output: 943_718 },
49
+ cost: { input: 0.016, output: 0.64 },
50
+ },
51
+ [ELLIOT_CODER_MODEL_ID]: {
52
+ name: "EAI Coder",
53
+ limit: { context: 1_048_576, output: 943_718 },
54
+ cost: { input: 0.016, output: 0.64 },
55
+ },
27
56
  }
28
57
 
29
58
  /**
@@ -0,0 +1,41 @@
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
+ * Stamp the OpenCode session id into a request's `metadata.session_id` so
26
+ * LiteLLM can route every turn of the same session to the same deployment.
27
+ * `options` is the same merged options object `chat.params` hands the caller
28
+ * (`opencode/.../session/llm/request.ts`), the one `metadata.guardrails`
29
+ * already travels in.
30
+ */
31
+ export function stampSessionAffinity(
32
+ options: Record<string, unknown> | null | undefined,
33
+ sessionID: string | undefined,
34
+ ): void {
35
+ if (!options || typeof options !== "object" || !sessionID) return
36
+ const metadata =
37
+ options.metadata && typeof options.metadata === "object" && !Array.isArray(options.metadata)
38
+ ? (options.metadata as Record<string, unknown>)
39
+ : (options.metadata = {} as Record<string, unknown>)
40
+ metadata["session_id"] = sessionID
41
+ }