@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.
- package/bin/eai +54 -2
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +364 -94
- package/lib/stage.js +60 -7
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- package/plugin/opencode-plugin/package.json +1 -1
- package/plugin/opencode-plugin/src/blocked-turns.ts +145 -0
- package/plugin/opencode-plugin/src/elliot-auth.ts +10 -0
- package/plugin/opencode-plugin/src/guardrail-sync.ts +330 -0
- package/plugin/opencode-plugin/src/identity-headers.ts +81 -0
- package/plugin/opencode-plugin/src/index.ts +118 -3
- package/plugin/opencode-plugin/src/mcp-keys.ts +4 -0
- package/plugin/opencode-plugin/src/proxy-provider.ts +31 -2
- package/plugin/opencode-plugin/src/session-affinity.ts +41 -0
- package/plugin/opencode-plugin/src/tui/format.ts +37 -0
- package/plugin/opencode-plugin/src/tui/sidebar.tsx +90 -0
- package/plugin/opencode-plugin/src/tui/usage.ts +132 -0
|
@@ -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 (
|
|
172
|
-
* model to it — no-op when the user isn't
|
|
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]: {
|
|
26
|
-
|
|
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
|
+
}
|