@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/bin/eai +48 -0
- package/lib/auth.js +173 -0
- package/lib/context.js +52 -0
- package/lib/login.js +114 -0
- package/lib/setup.js +152 -23
- package/lib/status.js +2 -0
- package/lib/teams.js +233 -0
- package/package.json +2 -2
- 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/index.ts +97 -0
- package/plugin/opencode-plugin/src/session-affinity.ts +41 -0
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.
|
|
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.
|
|
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
|