@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
|
@@ -71,3 +71,40 @@ export function formatDiffStat(additions: number, deletions: number): string {
|
|
|
71
71
|
export function pluralize(count: number, singular: string, plural = `${singular}s`): string {
|
|
72
72
|
return `${count} ${count === 1 ? singular : plural}`
|
|
73
73
|
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Mirror of the backend's own `_eai_money` (`backend/app/routers/mcp.py`),
|
|
77
|
+
* so a real spend figure read out of a `get_eai_usage` tool call renders
|
|
78
|
+
* identically whether a model relayed `summary`'s prose or this sidebar
|
|
79
|
+
* rendered the structured `current_spend`/`max_budget` fields directly.
|
|
80
|
+
*
|
|
81
|
+
* ~6 decimal places, widened only as far as needed so a genuinely nonzero
|
|
82
|
+
* spend never prints as "$0.000000" — four decimals rounds a value just
|
|
83
|
+
* under a round number (0.032999144) up to a falsely clean "$0.0330", which
|
|
84
|
+
* reads as exactly that amount when it is not.
|
|
85
|
+
*/
|
|
86
|
+
export function formatMoney(n: number): string {
|
|
87
|
+
if (n === 0) return "$0"
|
|
88
|
+
let decimals = 6
|
|
89
|
+
while (decimals < 10 && Number(Math.abs(n).toFixed(decimals)) === 0) decimals += 1
|
|
90
|
+
return `$${n.toFixed(decimals)}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mirror of the backend's own `_eai_date` — "DD Mon YYYY" from an ISO
|
|
95
|
+
* timestamp's date portion. Returns the input unchanged if it does not
|
|
96
|
+
* parse, the same "degrade, never throw" rule `_eai_date` follows, since this
|
|
97
|
+
* renders inside a sidebar slot where a thrown error would blank the chrome.
|
|
98
|
+
*/
|
|
99
|
+
export function formatEaiDate(iso: string): string {
|
|
100
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
|
|
101
|
+
if (!match) return iso
|
|
102
|
+
const [, y, m, d] = match
|
|
103
|
+
const months = [
|
|
104
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
105
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
106
|
+
]
|
|
107
|
+
const monthIndex = Number(m) - 1
|
|
108
|
+
if (monthIndex < 0 || monthIndex > 11) return iso
|
|
109
|
+
return `${d} ${months[monthIndex]} ${y}`
|
|
110
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { For, Show } from "solid-js"
|
|
16
16
|
|
|
17
17
|
import type { SessionSlotProps, TuiPluginApi } from "./api.js"
|
|
18
|
+
import { formatEaiDate, formatMoney } from "./format.js"
|
|
18
19
|
import {
|
|
19
20
|
collectKnowledgeResults,
|
|
20
21
|
distinctCitations,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
type Citation,
|
|
24
25
|
type KnowledgeResult,
|
|
25
26
|
} from "./knowledge.js"
|
|
27
|
+
import { collectUsageResults, latestUsage, type UsageResult } from "./usage.js"
|
|
26
28
|
|
|
27
29
|
/** A height-bounded panel: past this, rows push the built-in sections off. */
|
|
28
30
|
const MAX_CITATIONS = 6
|
|
@@ -84,6 +86,92 @@ function CitationRow(props: { api: TuiPluginApi; citation: Citation }) {
|
|
|
84
86
|
)
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Every `get_eai_usage` call in this session.
|
|
91
|
+
*
|
|
92
|
+
* Reads the transcript rather than calling the backend — same rule, same
|
|
93
|
+
* reason as `resultsFor` and `knowledge.ts` above it.
|
|
94
|
+
*/
|
|
95
|
+
function usageFor(api: TuiPluginApi, sessionID: string) {
|
|
96
|
+
const servers = elliotServerNames(api.state.mcp())
|
|
97
|
+
if (servers.length === 0) return []
|
|
98
|
+
|
|
99
|
+
const parts = api.state.session
|
|
100
|
+
.messages(sessionID)
|
|
101
|
+
.flatMap((message) => [...api.state.part(message.id)])
|
|
102
|
+
return collectUsageResults(parts, servers)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The real LiteLLM spend `/usage` already proved — not the stock "Context"
|
|
107
|
+
* panel's figure, which is a local token-count × static-price-table estimate
|
|
108
|
+
* that has no entry for Elliot's proxied `elliot-general`/`elliot-coder`
|
|
109
|
+
* model names and so is always $0.00 regardless of real usage (see
|
|
110
|
+
* `usage.ts`'s own module docstring for the full reasoning).
|
|
111
|
+
*
|
|
112
|
+
* Silent until `/usage` has run once this session: there is no second
|
|
113
|
+
* credential path here to fetch it proactively, so a single muted hint line
|
|
114
|
+
* is the honest state rather than a number that has not been proven yet.
|
|
115
|
+
*/
|
|
116
|
+
function UsageSection(props: { api: TuiPluginApi; slot: SessionSlotProps }) {
|
|
117
|
+
const theme = props.api.theme.current
|
|
118
|
+
const usage = () => latestUsage(usageFor(props.api, props.slot.session_id))
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<box>
|
|
122
|
+
<Label api={props.api}>EAI USAGE</Label>
|
|
123
|
+
<Show
|
|
124
|
+
when={usage()}
|
|
125
|
+
fallback={
|
|
126
|
+
<text fg={theme.textMuted} selectable={false}>
|
|
127
|
+
real LiteLLM spend here after you run /usage
|
|
128
|
+
</text>
|
|
129
|
+
}
|
|
130
|
+
>
|
|
131
|
+
{(u: () => UsageResult) => (
|
|
132
|
+
<Show
|
|
133
|
+
when={u().available}
|
|
134
|
+
fallback={
|
|
135
|
+
<text fg={theme.warning} selectable={false}>
|
|
136
|
+
EAI Gateway usage temporarily unavailable
|
|
137
|
+
</text>
|
|
138
|
+
}
|
|
139
|
+
>
|
|
140
|
+
<Show
|
|
141
|
+
when={u().provisioned}
|
|
142
|
+
fallback={
|
|
143
|
+
<text fg={theme.textMuted} selectable={false}>
|
|
144
|
+
personal EAI identity not set up yet
|
|
145
|
+
</text>
|
|
146
|
+
}
|
|
147
|
+
>
|
|
148
|
+
<Show
|
|
149
|
+
when={u().maxBudget !== null}
|
|
150
|
+
fallback={
|
|
151
|
+
<text fg={theme.text} selectable={true}>
|
|
152
|
+
{formatMoney(u().currentSpend)} spent (no limit set)
|
|
153
|
+
</text>
|
|
154
|
+
}
|
|
155
|
+
>
|
|
156
|
+
<text fg={theme.text} selectable={true}>
|
|
157
|
+
{formatMoney(u().currentSpend)} of {formatMoney(u().maxBudget ?? 0)}
|
|
158
|
+
{" "}
|
|
159
|
+
({Math.round((u().currentSpend / (u().maxBudget || 1)) * 100)}%)
|
|
160
|
+
</text>
|
|
161
|
+
</Show>
|
|
162
|
+
<Show when={u().budgetResetAt}>
|
|
163
|
+
<text fg={theme.textMuted} selectable={false}>
|
|
164
|
+
Resets: {formatEaiDate(u().budgetResetAt!)}
|
|
165
|
+
</text>
|
|
166
|
+
</Show>
|
|
167
|
+
</Show>
|
|
168
|
+
</Show>
|
|
169
|
+
)}
|
|
170
|
+
</Show>
|
|
171
|
+
</box>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
87
175
|
/** The one row that appears only on trouble. */
|
|
88
176
|
function Trouble(props: { api: TuiPluginApi }) {
|
|
89
177
|
const theme = props.api.theme.current
|
|
@@ -138,6 +226,8 @@ export function SidebarContent(props: { api: TuiPluginApi; slot: SessionSlotProp
|
|
|
138
226
|
</For>
|
|
139
227
|
</box>
|
|
140
228
|
</Show>
|
|
229
|
+
|
|
230
|
+
<UsageSection api={props.api} slot={props.slot} />
|
|
141
231
|
</box>
|
|
142
232
|
)
|
|
143
233
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading Elliot's REAL LiteLLM spend out of what the TUI already has.
|
|
3
|
+
*
|
|
4
|
+
* The stock OpenCode "Context" sidebar panel shows a cost figure too, but it
|
|
5
|
+
* is computed locally -- tokens × a static per-model price table
|
|
6
|
+
* (`session.ts`), looked up by provider+model id. Elliot's provider declares
|
|
7
|
+
* its models under the LiteLLM-alias names `elliot-general`/`elliot-coder`,
|
|
8
|
+
* which are not real upstream model ids, so that table has no entry for them
|
|
9
|
+
* and the figure is always $0.00 regardless of real usage. That is a
|
|
10
|
+
* structural property of a proxied provider, not a bug in the stock panel --
|
|
11
|
+
* it has no way to know Elliot's billing.
|
|
12
|
+
*
|
|
13
|
+
* This section shows the number that panel cannot: the REAL spend LiteLLM is
|
|
14
|
+
* tracking, which is already computed server-side by `get_eai_usage`
|
|
15
|
+
* (`/usage`'s own tool) and already proven correct — it is the SAME endpoint
|
|
16
|
+
* the Web Chat Settings -> Usage view reads.
|
|
17
|
+
*
|
|
18
|
+
* Reading it works exactly like `knowledge.ts`'s does, for the identical
|
|
19
|
+
* reason recorded there: the sidebar must not call the backend itself (a
|
|
20
|
+
* second credential path the server plugin's contract forbids). So real
|
|
21
|
+
* spend appears here only AFTER the user has run `/usage` at least once in
|
|
22
|
+
* this session — the number is read out of that completed tool call's own
|
|
23
|
+
* output, never fetched independently. No polling, no background refresh.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { USAGE_TOOL, toCanonicalToolUnderAnyGateway } from "../mcp-keys.js"
|
|
27
|
+
|
|
28
|
+
// ------------------------------------------------------------- host shapes --
|
|
29
|
+
|
|
30
|
+
/** A `ToolPart` in its completed state, narrowed to what usage needs. */
|
|
31
|
+
export interface ToolPartLike {
|
|
32
|
+
readonly type: string
|
|
33
|
+
readonly tool?: string
|
|
34
|
+
readonly state?: {
|
|
35
|
+
readonly status?: string
|
|
36
|
+
readonly output?: string
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------- parsing --
|
|
41
|
+
|
|
42
|
+
export type UsageResult = {
|
|
43
|
+
readonly available: boolean
|
|
44
|
+
readonly provisioned: boolean
|
|
45
|
+
readonly currentSpend: number
|
|
46
|
+
/** `null` means "no limit configured", a real and distinct state from
|
|
47
|
+
* "unknown" -- mirrors `PersonalUsageResponse.max_budget`. */
|
|
48
|
+
readonly maxBudget: number | null
|
|
49
|
+
readonly budgetResetAt: string | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Is this agent-visible tool key Elliot's own usage lookup?
|
|
54
|
+
*
|
|
55
|
+
* Exact composed-key match against the configured servers, the same rule
|
|
56
|
+
* `isKnowledgeTool` uses and for the same reason: a suffix match would let an
|
|
57
|
+
* unrelated server's tool be attributed to Elliot.
|
|
58
|
+
*/
|
|
59
|
+
export function isUsageTool(tool: string | undefined, serverNames: readonly string[]): boolean {
|
|
60
|
+
if (typeof tool !== "string") return false
|
|
61
|
+
return toCanonicalToolUnderAnyGateway(tool, serverNames) === USAGE_TOOL
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parse one completed `get_eai_usage` output.
|
|
66
|
+
*
|
|
67
|
+
* The output is the JSON-stringified dict `handle_get_eai_usage`
|
|
68
|
+
* (`backend/app/routers/mcp.py`) returns -- `{summary, available,
|
|
69
|
+
* provisioned, current_spend, max_budget, budget_reset_at}`. This reads the
|
|
70
|
+
* structured fields, never `summary`'s prose: that string is formatted for a
|
|
71
|
+
* model to relay verbatim and its exact wording is not a contract this file
|
|
72
|
+
* should depend on.
|
|
73
|
+
*
|
|
74
|
+
* Every branch returns `undefined` on anything unexpected, matching
|
|
75
|
+
* `parseKnowledgeOutput`'s own rule: silent rather than wrong if the backend
|
|
76
|
+
* shape changes, and `test/tui-usage.test.ts` locks the contract so a
|
|
77
|
+
* backend change breaks a test instead of the sidebar.
|
|
78
|
+
*/
|
|
79
|
+
export function parseUsageOutput(output: string | undefined): UsageResult | undefined {
|
|
80
|
+
if (!output) return undefined
|
|
81
|
+
|
|
82
|
+
let envelope: unknown
|
|
83
|
+
try {
|
|
84
|
+
envelope = JSON.parse(output)
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
if (typeof envelope !== "object" || envelope === null) return undefined
|
|
89
|
+
|
|
90
|
+
const record = envelope as Record<string, unknown>
|
|
91
|
+
const available = record["available"]
|
|
92
|
+
if (typeof available !== "boolean") return undefined
|
|
93
|
+
|
|
94
|
+
const provisioned = typeof record["provisioned"] === "boolean" ? record["provisioned"] : true
|
|
95
|
+
const currentSpend = typeof record["current_spend"] === "number" ? record["current_spend"] : 0
|
|
96
|
+
const maxBudget = typeof record["max_budget"] === "number" ? record["max_budget"] : null
|
|
97
|
+
const budgetResetAt = typeof record["budget_reset_at"] === "string" ? record["budget_reset_at"] : null
|
|
98
|
+
|
|
99
|
+
return { available, provisioned, currentSpend, maxBudget, budgetResetAt }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Every `get_eai_usage` call in this session, oldest first.
|
|
104
|
+
*
|
|
105
|
+
* Running and errored calls are skipped — "temporarily unavailable" already
|
|
106
|
+
* has its own `available: false` branch in the parsed result, and a
|
|
107
|
+
* still-running call has no output to parse at all.
|
|
108
|
+
*/
|
|
109
|
+
export function collectUsageResults(
|
|
110
|
+
parts: readonly ToolPartLike[],
|
|
111
|
+
serverNames: readonly string[],
|
|
112
|
+
): UsageResult[] {
|
|
113
|
+
const results: UsageResult[] = []
|
|
114
|
+
for (const part of parts) {
|
|
115
|
+
if (part.type !== "tool") continue
|
|
116
|
+
if (!isUsageTool(part.tool, serverNames)) continue
|
|
117
|
+
if (part.state?.status !== "completed") continue
|
|
118
|
+
const parsed = parseUsageOutput(part.state.output)
|
|
119
|
+
if (parsed) results.push(parsed)
|
|
120
|
+
}
|
|
121
|
+
return results
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The most recent usage snapshot in this session, or `undefined` if `/usage`
|
|
126
|
+
* has not been run yet. Spend is a point-in-time figure, so only the LATEST
|
|
127
|
+
* call is ever shown — unlike knowledge citations, earlier calls are not
|
|
128
|
+
* accumulated.
|
|
129
|
+
*/
|
|
130
|
+
export function latestUsage(results: readonly UsageResult[]): UsageResult | undefined {
|
|
131
|
+
return results.length > 0 ? results[results.length - 1] : undefined
|
|
132
|
+
}
|