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 +1 -1
- package/core/profile/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/profile/__pycache__/manager.cpython-313.pyc +0 -0
- package/dashboard/app/pages/budget.vue +328 -76
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +96 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.82.0
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
|
|
85
|
+
// ─── View tabs ───────────────────────────────────────────────────────────
|
|
5
86
|
|
|
6
|
-
|
|
7
|
-
const departments = computed(() => data.value?.departments ?? [])
|
|
8
|
-
const tiers = computed(() => data.value?.tiers ?? [])
|
|
87
|
+
type View = 'category' | 'provider' | 'model'
|
|
9
88
|
|
|
10
|
-
const
|
|
89
|
+
const view = ref<View>('category')
|
|
11
90
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 & 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
|
-
|
|
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="() =>
|
|
211
|
+
:on-retry="() => refreshAll()"
|
|
45
212
|
>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
<
|
|
54
|
-
|
|
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
|
-
<
|
|
58
|
-
|
|
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
|
-
<
|
|
62
|
-
|
|
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
|
|
65
|
-
<
|
|
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
|
-
</
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
84
|
-
:
|
|
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
|
-
</
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
class="
|
|
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
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -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")
|