@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.
@@ -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
+ }