@elliotsystems/eai 0.1.7 → 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/lib/status.js CHANGED
@@ -24,6 +24,7 @@
24
24
  const fs = require("fs")
25
25
 
26
26
  const { elliotConfigPath } = require("./setup.js")
27
+ const { billingContext } = require("./context.js")
27
28
 
28
29
  /** Read ~/.elliot/config.json, or null when absent/unreadable. */
29
30
  function readElliotConfig() {
@@ -107,6 +108,7 @@ async function runStatus(opts = {}) {
107
108
  log(`Workspace ID: ${config.tenant_id || "(unknown)"}`)
108
109
  if (config.stack) log(`Stack: ${config.stack}`)
109
110
  log(`Backend: ${config.backend_url || "(unset)"}`)
111
+ log(`Billing: ${billingContext(config).label}`)
110
112
 
111
113
  const healthy = config.backend_url ? await checkBackendHealth(config.backend_url) : false
112
114
  log(`Reachable: ${healthy ? "yes" : "no"}`)
package/lib/teams.js ADDED
@@ -0,0 +1,233 @@
1
+ "use strict"
2
+
3
+ /**
4
+ * `eai teams` — list, select, verify and clear the team billing context.
5
+ *
6
+ * Approved model (docs/architecture/EAI_ONBOARDING_BILLING_AUDIT.md §4/§6):
7
+ * the BACKEND is the only authority for membership and for the budgeted
8
+ * credential. These commands never trust a client-supplied team id as proof
9
+ * of membership — `GET /teams` returns only the caller's authorized teams,
10
+ * and `teams use`/`teams clear` re-fetch `/onboarding/config` through the
11
+ * exact provisioning path `eai setup` uses, so the materialised `opencode.json`
12
+ * carries the correct team (or personal) budgeted LiteLLM key. Fail-closed:
13
+ * a revoked membership, unknown/ambiguous team, or missing budgeted
14
+ * credential aborts with an actionable message and no config change.
15
+ *
16
+ * Switching teams rematerialises configuration and requires a FRESH `eai`
17
+ * session — OpenCode cannot swap the embedded key mid-session, so in-flight
18
+ * requests keep their original billing context (audit §3, decision 3).
19
+ */
20
+
21
+ const { ensureFreshAccessToken, authorizedGet } = require("./auth.js")
22
+ const { runSetup } = require("./setup.js")
23
+
24
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
25
+
26
+ /** Unwrap a /teams response whether the backend returns a bare array or an
27
+ * object with a `teams` key. */
28
+ function teamsFromBody(body) {
29
+ if (Array.isArray(body)) return body
30
+ if (body && Array.isArray(body.teams)) return body.teams
31
+ return []
32
+ }
33
+
34
+ /** Fetch the caller's authorized teams (empty on failure). */
35
+ async function fetchTeams(opts = {}) {
36
+ const session = await ensureFreshAccessToken(opts)
37
+ if (!session) return null
38
+ const { config } = session
39
+ if (!config.tenant_id) {
40
+ opts.log && opts.log("Your login has no tenant. Run `eai login` again.")
41
+ return null
42
+ }
43
+ const result = await authorizedGet(
44
+ `/teams?tenant_id=${encodeURIComponent(config.tenant_id)}`,
45
+ opts,
46
+ )
47
+ if (!result || !result.ok) return []
48
+ return teamsFromBody(result.body)
49
+ }
50
+
51
+ async function runTeamsList(opts = {}) {
52
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
53
+ const teams = await fetchTeams(opts)
54
+ if (teams === null) return 1
55
+ if (!teams.length) {
56
+ log("No teams are available to you yet.")
57
+ log("Ask an administrator to add you to a team, or run `eai` to use individual billing.")
58
+ return 0
59
+ }
60
+ log("Your teams:")
61
+ for (const team of teams) {
62
+ const name = team.name || "(unnamed)"
63
+ const budget =
64
+ typeof team.max_budget_usd === "number" && team.max_budget_usd > 0
65
+ ? ` — budget $${team.max_budget_usd}/day`
66
+ : ""
67
+ log(` ${name}${budget} (${team.id})`)
68
+ }
69
+ log("")
70
+ log('Select one with: eai teams use "<team name>"')
71
+ log("Show the active context with: eai teams current")
72
+ return 0
73
+ }
74
+
75
+ /** Resolve a `teams use` argument to a single team id, or null (with a
76
+ * message) when it is unknown or ambiguous. */
77
+ function resolveTeamId(teams, arg) {
78
+ if (UUID_RE.test(arg)) {
79
+ const match = teams.find((t) => String(t.id) === arg)
80
+ if (match) return { id: String(match.id), name: match.name }
81
+ return null
82
+ }
83
+ const matches = teams.filter((t) => String(t.name) === arg)
84
+ if (matches.length === 1) {
85
+ return { id: String(matches[0].id), name: matches[0].name }
86
+ }
87
+ if (matches.length > 1) {
88
+ const list = matches.map((t) => ` ${t.name} (${t.id})`).join("\n")
89
+ return {
90
+ ambiguous: true,
91
+ message:
92
+ `Multiple teams are named "${arg}". Use the id instead:\n${list}\n` +
93
+ ' eai teams use "<id>"',
94
+ }
95
+ }
96
+ return null
97
+ }
98
+
99
+ async function runTeamsUse(opts = {}) {
100
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
101
+ const arg = String(opts.team || "").trim()
102
+ if (!arg) {
103
+ log('Usage: eai teams use "<team name or id>"')
104
+ return 1
105
+ }
106
+ const session = await ensureFreshAccessToken(opts)
107
+ if (!session) return 1
108
+ const { token, config } = session
109
+ const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
110
+ if (!backendUrl) {
111
+ log("No backend configured. Run `eai login` first.")
112
+ return 1
113
+ }
114
+
115
+ const teams = await fetchTeams(opts)
116
+ if (teams === null) return 1
117
+ const resolved = resolveTeamId(teams, arg)
118
+ if (!resolved) {
119
+ log(`You are not a member of a team named or id'd "${arg}".`)
120
+ log("Run `eai teams` to see the teams available to you.")
121
+ return 1
122
+ }
123
+ if (resolved.ambiguous) {
124
+ log(resolved.message)
125
+ return 1
126
+ }
127
+
128
+ // Rematerialise through the exact setup path: the backend validates
129
+ // membership, resolves the TEAM's budgeted LiteLLM key (fail-closed if
130
+ // none resolves), and writes the config + opencode.json. A failure here
131
+ // leaves the previous config untouched.
132
+ log(`Selecting team "${resolved.name}"...`)
133
+ try {
134
+ await runSetup({ token, backendUrl, teamId: resolved.id, log })
135
+ } catch (error) {
136
+ log(String(error && error.message ? error.message : error))
137
+ log("Team selection did not change. Your previous billing context is unchanged.")
138
+ return 1
139
+ }
140
+ log("")
141
+ log(`Billing team set to "${resolved.name}".`)
142
+ log("Restart EAI (`eai`) to apply the new context. In-flight work is unaffected.")
143
+ return 0
144
+ }
145
+
146
+ async function runTeamsCurrent(opts = {}) {
147
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
148
+ const session = await ensureFreshAccessToken(opts)
149
+ if (!session) return 1
150
+ const { config } = session
151
+
152
+ const displayUser = config.email || config.user_id || "(unknown)"
153
+ const teamId = config.team_id
154
+ const isTeam = Boolean(teamId)
155
+
156
+ // Verify server-side, not from local config alone: resolve the team from
157
+ // the authorized list (revoked membership => 404/absent => unverified).
158
+ let teamName = null
159
+ let verified = false
160
+ const teams = await fetchTeams(opts)
161
+ if (teams) {
162
+ if (isTeam) {
163
+ const team = teams.find((t) => String(t.id) === String(teamId))
164
+ if (team) {
165
+ teamName = team.name
166
+ verified = true
167
+ }
168
+ } else {
169
+ // Individual billing: verified as long as we can fetch the authorized
170
+ // list (i.e. the session is live and the caller is an identified user).
171
+ verified = true
172
+ }
173
+ }
174
+
175
+ log("Elliot AI context")
176
+ log("─────────────────────────────")
177
+ log(`Signed in: ${displayUser}`)
178
+ if (isTeam && teamName) {
179
+ log(`Billing: Team — ${teamName}`)
180
+ } else if (isTeam && !teamName) {
181
+ log("Billing: Team (name unavailable)")
182
+ } else {
183
+ log("Billing: Individual")
184
+ }
185
+ log(`Status: ${verified ? "Verified" : "Unverified — cannot confirm with the backend"}`)
186
+ if (isTeam) {
187
+ log("Action: Restart EAI to apply the current context")
188
+ } else {
189
+ log("Action: none — requests are charged to your individual budget")
190
+ }
191
+ log("")
192
+ return verified ? 0 : 1
193
+ }
194
+
195
+ async function runTeamsClear(opts = {}) {
196
+ const log = opts.log || ((s) => process.stdout.write(s + "\n"))
197
+ const session = await ensureFreshAccessToken(opts)
198
+ if (!session) return 1
199
+ const { token, config } = session
200
+ const backendUrl = String(opts.backendUrl || config.backend_url || "").replace(/\/+$/, "")
201
+ if (!backendUrl) {
202
+ log("No backend configured. Run `eai login` first.")
203
+ return 1
204
+ }
205
+
206
+ log("Clearing team selection — reverting to individual billing...")
207
+ try {
208
+ // No team_id => /onboarding/config resolves the caller's PERSONAL key
209
+ // (fail-closed if none resolves — never the shared master key).
210
+ await runSetup({ token, backendUrl, log })
211
+ } catch (error) {
212
+ log(String(error && error.message ? error.message : error))
213
+ log(
214
+ "Individual billing could not be established. Model execution stays " +
215
+ "blocked until a personal budgeted credential is available. Contact " +
216
+ "your administrator.",
217
+ )
218
+ return 1
219
+ }
220
+ log("")
221
+ log("Team selection cleared — billing is now Individual.")
222
+ log("Restart EAI (`eai`) to apply the new context.")
223
+ return 0
224
+ }
225
+
226
+ module.exports = {
227
+ fetchTeams,
228
+ resolveTeamId,
229
+ runTeamsList,
230
+ runTeamsUse,
231
+ runTeamsCurrent,
232
+ runTeamsClear,
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elliotsystems/eai",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Elliot AI CLI entry point (eai) - launches the Elliot runtime with arguments forwarded verbatim.",
5
5
  "bin": {
6
6
  "eai": "./bin/eai"
@@ -27,6 +27,6 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "opencode-ai": "1.19.0"
30
+ "opencode-ai": "1.19.4"
31
31
  }
32
32
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Strips a Jev-blocked exchange out of what the MODEL sees, before every
3
+ * chat completion request.
4
+ *
5
+ * ## The bug this fixes
6
+ *
7
+ * Jev blocking a prompt stops it from ever reaching the model — but the
8
+ * user's raw text and the resulting error still live on in THIS session's
9
+ * own message history (needed for the UI: the "Request blocked" card's
10
+ * "Try again"/"Edit message" actions, and the transcript itself). OpenCode
11
+ * sends that FULL history with every later turn in the same session, so the
12
+ * very next (benign) prompt's completion request still carried the earlier,
13
+ * never-actually-sent "Help me steal someone's password" as an unanswered
14
+ * prior user turn.
15
+ *
16
+ * The model, seeing an unanswered request sitting in its own context,
17
+ * reasonably tries to be complete and addresses BOTH: the benign question
18
+ * asked just now, and the flagged one it was never actually asked to answer
19
+ * but can still see. Reported directly: asking for a C++ Hello World after
20
+ * an earlier blocked prompt produced a response that opened with a refusal
21
+ * about password theft before getting to the actual Hello World code —
22
+ * confusing, and a response to something that was never really asked of it
23
+ * in the first place (the guardrail is what stopped that request, not the
24
+ * model declining it; the model never got the chance to).
25
+ *
26
+ * ## The fix
27
+ *
28
+ * `experimental.chat.messages.transform` (opencode/packages/plugin/src/
29
+ * index.ts) runs right before the message history is converted to the
30
+ * provider's wire format (session/prompt.ts, immediately before
31
+ * `MessageV2.toModelMessagesEffect`). Removing a blocked exchange here means
32
+ * the model's context for the NEXT turn looks exactly as if the blocked
33
+ * prompt had never been sent at all — the benign follow-up is answered on
34
+ * its own, because that is the only thing the model is shown having been
35
+ * asked.
36
+ *
37
+ * This changes ONLY what the model is shown for a NEW completion request.
38
+ * It does not touch the stored session history (OpenCode's own message
39
+ * store, read by `GET .../messages`) at all, so the UI's own transcript —
40
+ * the blocked turn's card, Try again, Edit message — is completely
41
+ * unaffected; the user still sees exactly what happened. Only the model's
42
+ * own view of "what has this conversation actually asked of me" changes.
43
+ *
44
+ * ## Why in this plugin, not the backend
45
+ *
46
+ * The backend's own per-prompt isolation (jev_decisions_logic.py's
47
+ * `latest_turn_window`) already solves the ANALOGOUS problem for JEV
48
+ * ITSELF — a later prompt is judged on its own, not against an earlier
49
+ * flagged one. That fix has no effect here: it narrows what JEV sees when
50
+ * deciding whether to block, which is a completely different question from
51
+ * what the MODEL sees for an already-ALLOWED prompt. Once Jev allows a
52
+ * request through, this plugin — which sits on the client building that
53
+ * exact request — is the only thing positioned to also narrow what the
54
+ * model is shown.
55
+ */
56
+
57
+ /**
58
+ * Every wording `jev_policy_guardrail.py`'s `BLOCKED_MESSAGE` has had.
59
+ * Mirrors `terminal/src/opencode/guardrail.ts`'s own `KNOWN_BLOCKED_
60
+ * MESSAGES` deliberately rather than importing it (different package,
61
+ * different runtime — this one ships inside the OpenCode plugin host, that
62
+ * one inside the Web Chat bundle). Matched by substring for the same reason
63
+ * that file matches by substring: a message blocked under an older backend
64
+ * wording still carries that exact text in session history forever, and
65
+ * this must keep recognizing it as blocked rather than start leaking old
66
+ * blocked prompts into model context the moment the backend's wording next
67
+ * changes. Keep all three lists (here, guardrail.ts, and
68
+ * jev_policy_guardrail.py's own BLOCKED_MESSAGE) in sync.
69
+ */
70
+ const KNOWN_BLOCKED_MESSAGES = [
71
+ "Guardrail raised an exception, Guardrail: jev-policy, Message: Blocked by policy:",
72
+ "Request blocked by EAI's security policy. Please revise your request and try again.",
73
+ "This message couldn't be sent because it may violate our usage policy. Try rephrasing it, or ask something else.",
74
+ // The four stage/outcome-distinguishing wordings (Jev audit, 2026-10) --
75
+ // this file only ever needs flat yes/no detection (strip from model
76
+ // context, or don't), never which specific kind, so all four are listed
77
+ // here without the `BlockKind` tagging `terminal/src/opencode/
78
+ // guardrail.ts`'s own copy carries for its UI. Mirrors
79
+ // `jev_policy_logic.py`'s four `MESSAGE_*` constants exactly --
80
+ // `opencode-plugin/test/jev-blocked-message-parity.test.ts` pins it.
81
+ "This message was blocked by EAI's usage policy. Please rephrase your request and try again.",
82
+ "EAI couldn't verify this message against its usage policy in time. Please try sending it again.",
83
+ "EAI's usage policy blocked this response before it reached you. Try rephrasing your request, or ask something else.",
84
+ "EAI generated a response but couldn't verify it against its usage policy in time. Please try again.",
85
+ ]
86
+
87
+ interface MessageError {
88
+ name?: unknown
89
+ data?: { message?: unknown }
90
+ }
91
+
92
+ interface HistoryEntry {
93
+ info?: { role?: unknown; error?: unknown; [k: string]: unknown }
94
+ parts?: unknown[]
95
+ }
96
+
97
+ function isKnownBlockedText(message: unknown): boolean {
98
+ return typeof message === "string" && KNOWN_BLOCKED_MESSAGES.some((known) => message.includes(known))
99
+ }
100
+
101
+ /** `true` for an assistant turn whose `info.error` is a confirmed Jev
102
+ * guardrail block — never for an ordinary provider error (rate limit,
103
+ * context overflow, a dead connection), which the model legitimately
104
+ * needs to see to make sense of its own earlier failed attempt. */
105
+ export function isBlockedAssistantTurn(info: HistoryEntry["info"]): boolean {
106
+ const error = info?.error as MessageError | undefined
107
+ if (!error || error.name !== "APIError") return false
108
+ return isKnownBlockedText(error.data?.message)
109
+ }
110
+
111
+ /**
112
+ * Removes every blocked exchange (the blocked assistant turn AND the user
113
+ * turn immediately before it) from a session's message history. Returns the
114
+ * SAME array instance when nothing needed removing — the common case, so a
115
+ * normal, never-blocked session incurs no extra allocation on every turn.
116
+ *
117
+ * Walks backward from each blocked assistant turn to find its own user
118
+ * turn, skipping only turns already claimed by a LATER blocked exchange
119
+ * (so consecutive blocked turns in the same session each still find their
120
+ * own user message rather than double-claiming one). Stops looking the
121
+ * moment it hits another assistant turn first — an unexpected shape (two
122
+ * assistant turns with no user turn between them) is left alone rather
123
+ * than guessed at, since removing the wrong turn would be worse than
124
+ * removing none.
125
+ */
126
+ export function stripBlockedTurns<T extends HistoryEntry>(messages: T[]): T[] {
127
+ const drop = new Set<number>()
128
+
129
+ for (let i = 0; i < messages.length; i++) {
130
+ if (messages[i]?.info?.role !== "assistant" || !isBlockedAssistantTurn(messages[i].info)) continue
131
+ drop.add(i)
132
+ for (let j = i - 1; j >= 0; j--) {
133
+ if (drop.has(j)) continue
134
+ const role = messages[j]?.info?.role
135
+ if (role === "user") {
136
+ drop.add(j)
137
+ break
138
+ }
139
+ if (role === "assistant") break
140
+ }
141
+ }
142
+
143
+ if (drop.size === 0) return messages
144
+ return messages.filter((_, index) => !drop.has(index))
145
+ }
@@ -50,6 +50,16 @@ export interface ElliotConfig {
50
50
  backend_url?: string
51
51
  litellm_base_url?: string
52
52
  litellm_master_key?: string
53
+ /** The organization this config was written for — present since `eai
54
+ * setup` has always spread the onboarding response (which always
55
+ * includes it) into this file. Typed here (was previously only reachable
56
+ * through the index signature) because guardrail-sync.ts needs it to
57
+ * call `GET /teams/{team_id}` with the right tenant context. */
58
+ tenant_id?: string
59
+ /** The single team this config was scoped to at `eai setup` time — absent
60
+ * for a tenant-wide (owner/admin) setup, which has no one team to check
61
+ * a guardrail for. See guardrail-sync.ts. */
62
+ team_id?: string
53
63
  /**
54
64
  * Every credential this person can use, personal key first (Phase 4).
55
65
  * Absent on a config written by an older `eai setup` (or a backend that