arkaos 2.81.0 → 2.82.0

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/VERSION CHANGED
@@ -1 +1 @@
1
- 2.81.0
1
+ 2.82.0
@@ -1,36 +1,203 @@
1
1
  <script setup lang="ts">
2
+ // PR65 v2.82.0 — Budget rebuild backed by the PR47 LLM cost telemetry.
3
+ //
4
+ // The legacy /api/budget endpoint surfaces tokens-only ops counts. PR65
5
+ // adds /api/llm-costs (full PR47 CostSummary) and /api/llm-costs/trend
6
+ // (daily rollups), so this page can now show real cost USD by
7
+ // provider / model / category plus a 7-day spend chart.
8
+
9
+ interface BreakdownRow {
10
+ total_cost_usd: number | null
11
+ any_cost_known?: boolean
12
+ total_tokens_in: number
13
+ total_tokens_out: number
14
+ total_cached_tokens: number
15
+ call_count: number
16
+ cache_hit_rate: number
17
+ }
18
+
19
+ interface CostSummary {
20
+ period: string
21
+ total_cost_usd: number | null
22
+ total_tokens_in: number
23
+ total_tokens_out: number
24
+ total_cached_tokens: number
25
+ cache_hit_rate: number
26
+ call_count: number
27
+ by_provider: Record<string, BreakdownRow>
28
+ by_model: Record<string, BreakdownRow>
29
+ by_category: Record<string, BreakdownRow>
30
+ by_session: Array<{
31
+ session_id: string
32
+ call_count: number
33
+ total_tokens_in: number
34
+ total_tokens_out: number
35
+ total_cost_usd: number | null
36
+ }>
37
+ advisories: string[]
38
+ corrupt_line_count: number
39
+ }
40
+
41
+ interface TrendDay {
42
+ date: string
43
+ cost_usd: number | null
44
+ tokens_in: number
45
+ tokens_out: number
46
+ call_count: number
47
+ }
48
+
49
+ interface TrendResponse {
50
+ days: TrendDay[]
51
+ period_days: number
52
+ }
53
+
54
+ type Period = 'today' | 'week' | 'month' | 'all'
55
+
2
56
  const { fetchApi } = useApi()
57
+ const period = ref<Period>('today')
58
+
59
+ const periodOptions: { label: string; value: Period }[] = [
60
+ { label: 'Today', value: 'today' },
61
+ { label: '7 days', value: 'week' },
62
+ { label: '30 days', value: 'month' },
63
+ { label: 'All time', value: 'all' },
64
+ ]
65
+
66
+ const {
67
+ data: costs,
68
+ status,
69
+ error,
70
+ refresh,
71
+ } = fetchApi<CostSummary>(
72
+ '/api/llm-costs',
73
+ { query: computed(() => ({ period: period.value })) },
74
+ )
75
+
76
+ const {
77
+ data: trend,
78
+ refresh: refreshTrend,
79
+ } = fetchApi<TrendResponse>('/api/llm-costs/trend?days=7')
80
+
81
+ watch(period, async () => {
82
+ await refresh()
83
+ })
3
84
 
4
- const { data, status, error, refresh } = fetchApi<any>('/api/budget')
85
+ // ─── View tabs ───────────────────────────────────────────────────────────
5
86
 
6
- const summary = computed(() => data.value?.summary ?? { total_tokens: 0, total_ops: 0, active_departments: 0, estimated_cost_usd: 0 })
7
- const departments = computed(() => data.value?.departments ?? [])
8
- const tiers = computed(() => data.value?.tiers ?? [])
87
+ type View = 'category' | 'provider' | 'model'
9
88
 
10
- const showLimits = ref(false)
89
+ const view = ref<View>('category')
11
90
 
12
- const tierLabels: Record<number, string> = {
13
- 0: 'C-Suite (Unlimited)',
14
- 1: 'Squad Leads',
15
- 2: 'Specialists',
16
- 3: 'Support'
91
+ const viewTabs = [
92
+ { label: 'By category', value: 'category' as const },
93
+ { label: 'By provider', value: 'provider' as const },
94
+ { label: 'By model', value: 'model' as const },
95
+ ]
96
+
97
+ const breakdownRows = computed<Array<[string, BreakdownRow]>>(() => {
98
+ const source = (() => {
99
+ switch (view.value) {
100
+ case 'category': return costs.value?.by_category ?? {}
101
+ case 'provider': return costs.value?.by_provider ?? {}
102
+ case 'model': return costs.value?.by_model ?? {}
103
+ }
104
+ })()
105
+ return Object.entries(source).sort((a, b) => {
106
+ const ca = a[1].total_cost_usd ?? 0
107
+ const cb = b[1].total_cost_usd ?? 0
108
+ return cb - ca
109
+ })
110
+ })
111
+
112
+ const viewEmpty = computed(() =>
113
+ breakdownRows.value.length === 0
114
+ || (
115
+ view.value === 'category'
116
+ && breakdownRows.value.length === 1
117
+ && breakdownRows.value[0]?.[0] === ''
118
+ ),
119
+ )
120
+
121
+ const maxCostForBar = computed(() => {
122
+ const max = breakdownRows.value.reduce(
123
+ (acc, [, row]) => Math.max(acc, row.total_cost_usd ?? 0),
124
+ 0,
125
+ )
126
+ return max > 0 ? max : 1
127
+ })
128
+
129
+ function formatCost(value: number | null): string {
130
+ if (value === null || value === undefined) return 'n/a'
131
+ if (value === 0) return '$0'
132
+ if (value < 0.01) return `$${value.toFixed(4)}`
133
+ return `$${value.toFixed(2)}`
134
+ }
135
+
136
+ function formatTokens(value: number): string {
137
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
138
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
139
+ return value.toString()
140
+ }
141
+
142
+ function formatLabel(key: string): string {
143
+ if (!key) return '(base / uncategorised)'
144
+ return key
145
+ }
146
+
147
+ // ─── Trend chart ────────────────────────────────────────────────────────
148
+
149
+ const maxTrendCost = computed(() => {
150
+ if (!trend.value?.days?.length) return 1
151
+ const max = trend.value.days.reduce(
152
+ (acc, d) => Math.max(acc, d.cost_usd ?? 0),
153
+ 0,
154
+ )
155
+ return max > 0 ? max : 1
156
+ })
157
+
158
+ function trendBarHeight(day: TrendDay): string {
159
+ const cost = day.cost_usd ?? 0
160
+ const percent = Math.min(100, (cost / maxTrendCost.value) * 100)
161
+ return `${Math.max(percent, day.call_count > 0 ? 4 : 0)}%`
162
+ }
163
+
164
+ function trendDayLabel(iso: string): string {
165
+ try {
166
+ const d = new Date(iso + 'T00:00:00Z')
167
+ return new Intl.DateTimeFormat('en-US', {
168
+ weekday: 'short',
169
+ }).format(d)
170
+ } catch {
171
+ return iso
172
+ }
173
+ }
174
+
175
+ async function refreshAll() {
176
+ await Promise.all([refresh(), refreshTrend()])
17
177
  }
18
178
  </script>
19
179
 
20
180
  <template>
21
181
  <UDashboardPanel id="budget">
22
182
  <template #header>
23
- <UDashboardNavbar title="Usage & Budget">
183
+ <UDashboardNavbar title="Usage &amp; Budget">
24
184
  <template #leading>
25
185
  <UDashboardSidebarCollapse />
26
186
  </template>
27
187
  <template #right>
188
+ <USelect
189
+ v-model="period"
190
+ :items="periodOptions"
191
+ size="sm"
192
+ class="w-32"
193
+ />
28
194
  <UButton
29
195
  label="Refresh"
30
196
  variant="ghost"
31
197
  icon="i-lucide-refresh-cw"
32
198
  size="sm"
33
- @click="refresh()"
199
+ class="ml-2"
200
+ @click="refreshAll"
34
201
  />
35
202
  </template>
36
203
  </UDashboardNavbar>
@@ -41,88 +208,173 @@ const tierLabels: Record<number, string> = {
41
208
  :status="status"
42
209
  :error="error"
43
210
  loading-label="Loading budget"
44
- :on-retry="() => refresh()"
211
+ :on-retry="() => refreshAll()"
45
212
  >
46
- <div class="space-y-6">
47
- <!-- Monthly Summary -->
48
- <UCard>
49
- <div class="space-y-3">
50
- <p class="text-xs font-semibold text-muted uppercase tracking-wider">This Month's Usage</p>
51
- <div class="flex flex-wrap items-baseline gap-6">
213
+ <div class="space-y-6">
214
+ <!-- Top-line summary -->
215
+ <UCard>
216
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
52
217
  <div>
53
- <span class="text-3xl font-bold">{{ summary.total_tokens.toLocaleString() }}</span>
54
- <span class="text-sm text-muted ml-1">tokens</span>
218
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
219
+ Total cost
220
+ </p>
221
+ <p class="text-2xl font-bold">
222
+ {{ formatCost(costs?.total_cost_usd ?? null) }}
223
+ </p>
55
224
  </div>
56
225
  <div>
57
- <span class="text-xl font-semibold">{{ summary.total_ops }}</span>
58
- <span class="text-sm text-muted ml-1">operations</span>
226
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
227
+ Calls
228
+ </p>
229
+ <p class="text-2xl font-bold">{{ costs?.call_count ?? 0 }}</p>
59
230
  </div>
60
231
  <div>
61
- <span class="text-xl font-semibold">{{ summary.active_departments }}</span>
62
- <span class="text-sm text-muted ml-1">departments active</span>
232
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
233
+ Tokens in / out
234
+ </p>
235
+ <p class="text-lg font-semibold">
236
+ {{ formatTokens(costs?.total_tokens_in ?? 0) }} /
237
+ {{ formatTokens(costs?.total_tokens_out ?? 0) }}
238
+ </p>
63
239
  </div>
64
- <div v-if="summary.estimated_cost_usd > 0">
65
- <span class="text-sm text-muted">Est. cost: ~${{ summary.estimated_cost_usd.toFixed(4) }}</span>
240
+ <div>
241
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">
242
+ Cache hit rate
243
+ </p>
244
+ <p class="text-2xl font-bold">
245
+ {{ ((costs?.cache_hit_rate ?? 0) * 100).toFixed(1) }}%
246
+ </p>
66
247
  </div>
67
248
  </div>
68
- </div>
69
- </UCard>
70
-
71
- <!-- Department Breakdown -->
72
- <div v-if="departments.length">
73
- <h2 class="text-sm font-semibold text-muted uppercase tracking-wider mb-4">Usage by Department</h2>
74
- <div class="space-y-3">
75
- <div
76
- v-for="dept in departments"
77
- :key="dept.department"
78
- class="flex items-center gap-4"
79
- >
80
- <span class="w-28 text-sm font-medium truncate">{{ dept.department }}</span>
81
- <div class="flex-1 h-3 rounded-full bg-muted/20 overflow-hidden">
249
+ </UCard>
250
+
251
+ <!-- 7-day trend (inline bar chart) -->
252
+ <UCard v-if="trend?.days?.length">
253
+ <div>
254
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-4">
255
+ Last 7 days
256
+ </p>
257
+ <div class="flex items-end gap-2 h-32">
82
258
  <div
83
- class="h-3 rounded-full bg-primary transition-none"
84
- :style="{ width: `${dept.percent}%` }"
85
- />
259
+ v-for="day in trend.days"
260
+ :key="day.date"
261
+ class="flex-1 flex flex-col items-center gap-1"
262
+ :title="`${day.date} — ${formatCost(day.cost_usd)} (${day.call_count} calls)`"
263
+ >
264
+ <div class="w-full h-full flex items-end">
265
+ <div
266
+ class="w-full rounded-t transition-none"
267
+ :class="day.call_count > 0 ? 'bg-primary' : 'bg-muted/20'"
268
+ :style="{ height: trendBarHeight(day) }"
269
+ />
270
+ </div>
271
+ <span class="text-xs text-muted">{{ trendDayLabel(day.date) }}</span>
272
+ </div>
86
273
  </div>
87
- <span class="w-24 text-right text-sm font-mono">{{ dept.tokens.toLocaleString() }}</span>
88
- <span class="w-16 text-right text-xs text-muted">{{ dept.operations }} ops</span>
89
274
  </div>
90
- </div>
91
- </div>
92
-
93
- <!-- Empty state -->
94
- <div v-else class="flex flex-col items-center justify-center gap-4 py-12">
95
- <UIcon name="i-lucide-bar-chart-3" class="size-12 text-muted" />
96
- <p class="text-sm text-muted">No usage data yet.</p>
97
- <p class="text-xs text-muted">Token usage is tracked automatically when ArkaOS processes prompts and indexes knowledge.</p>
98
- </div>
275
+ </UCard>
99
276
 
100
- <!-- System Limits (collapsible) -->
101
- <div class="pt-4 border-t border-default">
102
- <button
103
- class="flex items-center gap-2 text-xs text-muted hover:text-highlighted transition-colors"
104
- @click="showLimits = !showLimits"
277
+ <!-- Advisories -->
278
+ <UCard
279
+ v-if="costs?.advisories?.length"
280
+ class="border-yellow-500/30 bg-yellow-500/5"
105
281
  >
106
- <UIcon :name="showLimits ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'" class="size-3" />
107
- System Limits
108
- </button>
109
-
110
- <div v-if="showLimits" class="mt-3 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
111
- <div
112
- v-for="tier in tiers"
113
- :key="tier.tier"
114
- class="rounded-lg border border-default p-3"
115
- >
116
- <p class="text-xs font-semibold">Tier {{ tier.tier }}</p>
117
- <p class="text-xs text-muted">{{ tierLabels[tier.tier] ?? '' }}</p>
118
- <p class="text-xs text-muted mt-1">
119
- <template v-if="tier.is_unlimited">Unlimited</template>
120
- <template v-else>{{ (tier.allocated ?? 0).toLocaleString() }} tokens/month</template>
282
+ <div class="flex items-start gap-3">
283
+ <UIcon
284
+ name="i-lucide-alert-triangle"
285
+ class="size-5 text-yellow-500 mt-0.5 shrink-0"
286
+ />
287
+ <div>
288
+ <p class="text-sm font-semibold text-yellow-500 mb-2">
289
+ Advisories
290
+ </p>
291
+ <ul class="space-y-1 text-sm">
292
+ <li v-for="a in costs.advisories" :key="a">
293
+ {{ a }}
294
+ </li>
295
+ </ul>
296
+ </div>
297
+ </div>
298
+ </UCard>
299
+
300
+ <!-- Breakdown views -->
301
+ <div>
302
+ <UTabs
303
+ v-model="view"
304
+ :items="viewTabs"
305
+ :ui="{ list: 'mb-4' }"
306
+ />
307
+
308
+ <div v-if="viewEmpty" class="flex flex-col items-center justify-center gap-3 py-12 rounded-lg border border-default">
309
+ <UIcon name="i-lucide-bar-chart-3" class="size-10 text-muted" />
310
+ <p class="text-sm text-muted">
311
+ {{ view === 'category'
312
+ ? "No category-aware spend yet. Orchestration layers can set ARKA_CALL_CATEGORY before LLM calls to attribute spend (PR60)."
313
+ : "No spend data yet for this view." }}
121
314
  </p>
122
315
  </div>
316
+
317
+ <div v-else class="space-y-2">
318
+ <div
319
+ v-for="[key, row] in breakdownRows"
320
+ :key="key"
321
+ class="flex items-center gap-3 rounded-lg border border-default p-3"
322
+ >
323
+ <span class="w-48 text-sm font-mono truncate" :title="formatLabel(key)">
324
+ {{ formatLabel(key) }}
325
+ </span>
326
+ <div class="flex-1 h-3 rounded-full bg-muted/10 overflow-hidden">
327
+ <div
328
+ class="h-3 rounded-full bg-primary"
329
+ :style="{
330
+ width: `${Math.max(2, ((row.total_cost_usd ?? 0) / maxCostForBar) * 100)}%`,
331
+ }"
332
+ />
333
+ </div>
334
+ <span class="w-20 text-right text-sm font-mono">
335
+ {{ formatCost(row.total_cost_usd) }}
336
+ </span>
337
+ <span class="w-16 text-right text-xs text-muted">
338
+ {{ row.call_count }} calls
339
+ </span>
340
+ <span class="w-20 text-right text-xs text-muted">
341
+ {{ formatTokens(row.total_tokens_in + row.total_tokens_out) }} tok
342
+ </span>
343
+ </div>
344
+ </div>
123
345
  </div>
346
+
347
+ <!-- Top sessions -->
348
+ <UCard v-if="costs?.by_session?.length">
349
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider mb-3">
350
+ Top sessions
351
+ </p>
352
+ <div class="space-y-2">
353
+ <div
354
+ v-for="s in costs.by_session"
355
+ :key="s.session_id || 'unknown'"
356
+ class="flex items-center gap-3 text-sm"
357
+ >
358
+ <span class="w-48 font-mono text-xs truncate">
359
+ {{ s.session_id || '(unknown)' }}
360
+ </span>
361
+ <span class="flex-1 text-muted">{{ s.call_count }} calls</span>
362
+ <span class="w-20 text-right font-mono">{{ formatCost(s.total_cost_usd) }}</span>
363
+ <span class="w-20 text-right text-xs text-muted">
364
+ {{ formatTokens(s.total_tokens_in + s.total_tokens_out) }} tok
365
+ </span>
366
+ </div>
367
+ </div>
368
+ </UCard>
369
+
370
+ <!-- Note for corrupt rows -->
371
+ <p
372
+ v-if="costs?.corrupt_line_count"
373
+ class="text-xs text-muted text-center"
374
+ >
375
+ Skipped {{ costs.corrupt_line_count }} corrupt JSONL line(s) in telemetry.
376
+ </p>
124
377
  </div>
125
- </div>
126
378
  </DashboardState>
127
379
  </template>
128
380
  </UDashboardPanel>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.81.0",
3
+ "version": "2.82.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "2.81.0"
3
+ version = "2.82.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -729,6 +729,102 @@ def persona_build(body: dict):
729
729
  }
730
730
 
731
731
 
732
+ # --- LLM Costs (PR65 v2.82.0) ---
733
+
734
+ @app.get("/api/llm-costs")
735
+ def llm_costs(period: str = "today"):
736
+ """Aggregated LLM cost summary backed by the PR47 telemetry pipeline.
737
+
738
+ `period` ∈ {today, week, month, all}. Returns the same shape the
739
+ `/arka costs` CLI returns — by_provider, by_model, by_category
740
+ (PR47), top sessions, advisories. The Budget page consumes this
741
+ to show category-aware spend instead of the legacy
742
+ /api/budget tokens-only view.
743
+ """
744
+ try:
745
+ from core.runtime.llm_cost_telemetry import summarise, VALID_PERIODS
746
+ except Exception as exc: # pragma: no cover - import guard
747
+ return {"error": f"telemetry unavailable: {exc}"}
748
+ if period not in VALID_PERIODS:
749
+ return {"error": f"period must be one of {list(VALID_PERIODS)}"}
750
+ summary = summarise(period=period)
751
+ return {
752
+ "period": summary.period,
753
+ "total_cost_usd": summary.total_cost_usd,
754
+ "total_tokens_in": summary.total_tokens_in,
755
+ "total_tokens_out": summary.total_tokens_out,
756
+ "total_cached_tokens": summary.total_cached_tokens,
757
+ "cache_hit_rate": summary.cache_hit_rate,
758
+ "call_count": summary.call_count,
759
+ "by_provider": summary.by_provider,
760
+ "by_model": summary.by_model,
761
+ "by_category": summary.by_category,
762
+ "by_session": summary.by_session,
763
+ "advisories": summary.advisories,
764
+ "corrupt_line_count": summary.corrupt_line_count,
765
+ }
766
+
767
+
768
+ @app.get("/api/llm-costs/trend")
769
+ def llm_costs_trend(days: int = 7):
770
+ """Day-by-day rolling totals from the cost telemetry.
771
+
772
+ Returns ``{"days": [{"date": "YYYY-MM-DD", "cost_usd": x.xx,
773
+ "tokens_in": N, "tokens_out": N, "call_count": N}]}`` so the
774
+ Budget page can render a 7-day trend chart with @unovis/vue.
775
+ Cap `days` to 90 to keep response size bounded.
776
+ """
777
+ from datetime import datetime, timedelta, timezone
778
+ from core.runtime.llm_cost_telemetry import read_entries
779
+ # `days or 7` would treat 0 as "use default", which contradicts the
780
+ # documented floor-at-1 behaviour. Cast first, then clamp.
781
+ try:
782
+ days_int = int(days) if days is not None else 7
783
+ except (TypeError, ValueError):
784
+ days_int = 7
785
+ capped_days = max(1, min(days_int, 90))
786
+ today = datetime.now(timezone.utc).date()
787
+ buckets: dict[str, dict] = {}
788
+ # Seed every day so the chart shows zeros instead of gaps.
789
+ for offset in range(capped_days):
790
+ d = today - timedelta(days=capped_days - 1 - offset)
791
+ buckets[d.isoformat()] = {
792
+ "date": d.isoformat(),
793
+ "cost_usd": 0.0,
794
+ "cost_known": False,
795
+ "tokens_in": 0,
796
+ "tokens_out": 0,
797
+ "call_count": 0,
798
+ }
799
+ cutoff = today - timedelta(days=capped_days - 1)
800
+ for entry in read_entries():
801
+ raw_ts = entry.get("ts") or ""
802
+ if not isinstance(raw_ts, str):
803
+ continue
804
+ try:
805
+ ts = datetime.fromisoformat(raw_ts.replace("Z", "+00:00"))
806
+ except ValueError:
807
+ continue
808
+ d = ts.date()
809
+ if d < cutoff:
810
+ continue
811
+ key = d.isoformat()
812
+ if key not in buckets:
813
+ continue
814
+ b = buckets[key]
815
+ b["tokens_in"] += int(entry.get("tokens_in") or 0)
816
+ b["tokens_out"] += int(entry.get("tokens_out") or 0)
817
+ b["call_count"] += 1
818
+ cost = entry.get("estimated_cost_usd")
819
+ if cost is not None:
820
+ b["cost_usd"] += float(cost)
821
+ b["cost_known"] = True
822
+ out_days = list(buckets.values())
823
+ for b in out_days:
824
+ b["cost_usd"] = round(b["cost_usd"], 6) if b.pop("cost_known") else None
825
+ return {"days": out_days, "period_days": capped_days}
826
+
827
+
732
828
  # --- Profile (PR63 v2.81.0) ---
733
829
 
734
830
  @app.get("/api/profile")