arkaos 2.82.0 → 2.83.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.82.0
1
+ 2.83.0
@@ -82,7 +82,11 @@ class ProfileManager:
82
82
  """
83
83
 
84
84
  def __init__(self, path: Path | None = None) -> None:
85
- self._path = path or DEFAULT_PROFILE_PATH
85
+ # Resolve at call time so HOME changes (tests, multi-tenant
86
+ # daemons) are honoured. DEFAULT_PROFILE_PATH stays as a
87
+ # module-level constant for callers that want the canonical
88
+ # path explicitly.
89
+ self._path = path or (Path.home() / ".arkaos" / "profile.json")
86
90
 
87
91
  @property
88
92
  def path(self) -> Path:
@@ -1,39 +1,139 @@
1
1
  <script setup lang="ts">
2
- import type { OverviewData } from '~/types'
2
+ // PR66 v2.83.0 Command center.
3
+ //
4
+ // Replaces the 6-stat-card Overview that just counted things you
5
+ // already knew (agents=62, skills=256, ...) with telemetry-driven
6
+ // information the operator actually uses: greeting, today's cost,
7
+ // per-project state, recent incidents, quick actions.
8
+
9
+ interface ProjectRow {
10
+ name: string
11
+ path: string
12
+ stack: string[]
13
+ status: string
14
+ ecosystem: string
15
+ last_commit_days: number | null
16
+ }
17
+
18
+ interface IncidentRow {
19
+ ts: string
20
+ tool: string
21
+ reason: string
22
+ cwd: string
23
+ bypass_used: boolean
24
+ kind: 'bypass' | 'blocked'
25
+ }
26
+
27
+ interface QuickAction {
28
+ command: string
29
+ description: string
30
+ }
31
+
32
+ interface CommandCenterPayload {
33
+ greeting: {
34
+ name: string
35
+ role: string
36
+ company: string
37
+ language: string
38
+ }
39
+ today_cost: {
40
+ total_usd: number | null
41
+ call_count: number
42
+ tokens_in: number
43
+ tokens_out: number
44
+ cache_hit_rate: number
45
+ }
46
+ projects: ProjectRow[]
47
+ recent_incidents: IncidentRow[]
48
+ quick_actions: QuickAction[]
49
+ }
3
50
 
4
51
  const { fetchApi } = useApi()
5
52
 
6
- const { data: overview, status, error, refresh } = await fetchApi<OverviewData>('/api/overview')
53
+ const {
54
+ data,
55
+ status,
56
+ error,
57
+ refresh,
58
+ } = await fetchApi<CommandCenterPayload>('/api/overview/command-center')
59
+
60
+ const greetingLabel = computed(() => {
61
+ const name = data.value?.greeting?.name?.trim()
62
+ const language = data.value?.greeting?.language ?? 'en'
63
+ if (!name) return language === 'pt' ? 'Olá' : 'Welcome'
64
+ return language === 'pt' ? `Olá, ${name}` : `Hi, ${name}`
65
+ })
66
+
67
+ const todayCost = computed(() => data.value?.today_cost)
68
+
69
+ function formatCost(value: number | null | undefined): string {
70
+ if (value === null || value === undefined) return 'n/a'
71
+ if (value === 0) return '$0'
72
+ if (value < 0.01) return `$${value.toFixed(4)}`
73
+ return `$${value.toFixed(2)}`
74
+ }
75
+
76
+ function formatTokens(n: number): string {
77
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
78
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
79
+ return n.toString()
80
+ }
81
+
82
+ function statusColor(status: string): 'success' | 'warning' | 'neutral' | 'error' {
83
+ switch (status) {
84
+ case 'active': return 'success'
85
+ case 'paused': return 'warning'
86
+ case 'archived': return 'neutral'
87
+ case 'error': return 'error'
88
+ default: return 'neutral'
89
+ }
90
+ }
7
91
 
8
- const stats = computed(() => [
9
- { label: 'Agents', value: overview.value?.agents ?? 0, icon: 'i-lucide-users' },
10
- { label: 'Skills', value: overview.value?.skills ?? 0, icon: 'i-lucide-sparkles' },
11
- { label: 'Departments', value: overview.value?.departments ?? 0, icon: 'i-lucide-building-2' },
12
- { label: 'Tests', value: overview.value?.tests ?? 0, icon: 'i-lucide-test-tubes' },
13
- { label: 'Commands', value: overview.value?.commands ?? 0, icon: 'i-lucide-terminal' },
14
- { label: 'Workflows', value: overview.value?.workflows ?? 0, icon: 'i-lucide-git-branch' }
15
- ])
92
+ function commitFreshness(days: number | null): { color: string; label: string } {
93
+ if (days === null) return { color: 'text-muted', label: 'no git' }
94
+ if (days === 0) return { color: 'text-green-500', label: 'today' }
95
+ if (days === 1) return { color: 'text-green-500', label: '1 day ago' }
96
+ if (days < 7) return { color: 'text-primary', label: `${days} days ago` }
97
+ if (days < 30) return { color: 'text-yellow-500', label: `${days} days ago` }
98
+ return { color: 'text-red-500', label: `${days} days ago` }
99
+ }
16
100
 
17
- const budgetPercent = computed(() => overview.value?.budget?.percent_used ?? 0)
18
- const budgetUsed = computed(() => overview.value?.budget?.used ?? 0)
19
- const budgetAllocated = computed(() => overview.value?.budget?.allocated ?? 0)
20
- const budgetUnlimited = computed(() => overview.value?.budget?.is_unlimited ?? false)
101
+ function formatIncidentTs(iso: string): string {
102
+ if (!iso) return ''
103
+ try {
104
+ return new Intl.DateTimeFormat('en-US', {
105
+ month: 'short',
106
+ day: 'numeric',
107
+ hour: '2-digit',
108
+ minute: '2-digit',
109
+ }).format(new Date(iso))
110
+ } catch {
111
+ return iso
112
+ }
113
+ }
21
114
 
22
- function formatCurrency(value: number): string {
23
- return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(value)
115
+ function copyCommand(cmd: string) {
116
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
117
+ navigator.clipboard.writeText(cmd).catch(() => { /* ignore */ })
118
+ }
24
119
  }
25
120
  </script>
26
121
 
27
122
  <template>
28
123
  <UDashboardPanel id="overview">
29
124
  <template #header>
30
- <UDashboardNavbar title="Overview">
125
+ <UDashboardNavbar title="Command Center">
31
126
  <template #leading>
32
127
  <UDashboardSidebarCollapse />
33
128
  </template>
34
-
35
129
  <template #right>
36
- <UBadge v-if="overview?.version" :label="`v${overview.version}`" variant="subtle" color="primary" />
130
+ <UButton
131
+ label="Refresh"
132
+ variant="ghost"
133
+ icon="i-lucide-refresh-cw"
134
+ size="sm"
135
+ @click="refresh()"
136
+ />
37
137
  </template>
38
138
  </UDashboardNavbar>
39
139
  </template>
@@ -42,72 +142,171 @@ function formatCurrency(value: number): string {
42
142
  <DashboardState
43
143
  :status="status"
44
144
  :error="error"
45
- loading-label="Loading overview"
145
+ loading-label="Loading command center"
46
146
  :on-retry="() => refresh()"
47
147
  >
48
- <!-- Stats Grid -->
49
- <div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
50
- <div
51
- v-for="stat in stats"
52
- :key="stat.label"
53
- class="flex flex-col gap-2 rounded-lg border border-default p-4"
54
- >
55
- <div class="flex items-center gap-2">
56
- <UIcon :name="stat.icon" class="size-4 text-muted" />
57
- <span class="text-sm text-muted">{{ stat.label }}</span>
58
- </div>
59
- <span class="text-2xl font-semibold text-highlighted">{{ stat.value }}</span>
60
- </div>
61
- </div>
62
-
63
- <!-- Budget Gauge -->
64
- <div class="mt-6 rounded-lg border border-default p-6">
65
- <h3 class="mb-4 text-lg font-semibold text-highlighted">Budget</h3>
66
- <div v-if="budgetUnlimited" class="text-sm text-muted">
67
- Unlimited budget configured.
68
- </div>
69
- <div v-else class="space-y-3">
70
- <div class="flex items-center justify-between text-sm">
71
- <span class="text-muted">Used: {{ formatCurrency(budgetUsed) }}</span>
72
- <span class="text-muted">Allocated: {{ formatCurrency(budgetAllocated) }}</span>
73
- </div>
74
- <UProgress :value="budgetPercent" :max="100" size="md" />
75
- <p class="text-xs text-muted">{{ budgetPercent.toFixed(1) }}% of budget used</p>
76
- </div>
77
- </div>
78
-
79
- <!-- Bottom Row: Tasks + Knowledge -->
80
- <div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
81
- <!-- Tasks Summary -->
82
- <div class="rounded-lg border border-default p-6">
83
- <h3 class="mb-4 text-lg font-semibold text-highlighted">Tasks</h3>
84
- <div class="grid grid-cols-3 gap-4">
85
- <div class="text-center">
86
- <p class="text-2xl font-semibold text-highlighted">{{ overview?.tasks?.total ?? 0 }}</p>
87
- <p class="text-xs text-muted">Total</p>
148
+ <div class="space-y-6">
149
+ <!-- Hero: greeting + today's cost -->
150
+ <UCard>
151
+ <div class="flex flex-col gap-2 md:flex-row md:items-baseline md:justify-between">
152
+ <div>
153
+ <h1 class="text-2xl font-bold">{{ greetingLabel }}.</h1>
154
+ <p class="text-sm text-muted mt-1">
155
+ <template v-if="data?.greeting?.role && data?.greeting?.company">
156
+ {{ data.greeting.role }} @ {{ data.greeting.company }}
157
+ </template>
158
+ <template v-else>
159
+ Set your profile in Settings to personalise this view.
160
+ </template>
161
+ </p>
88
162
  </div>
89
- <div class="text-center">
90
- <p class="text-2xl font-semibold text-primary">{{ overview?.tasks?.active ?? 0 }}</p>
91
- <p class="text-xs text-muted">Active</p>
163
+ <div v-if="todayCost" class="flex items-baseline gap-6 text-right">
164
+ <div>
165
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
166
+ Today's cost
167
+ </p>
168
+ <p class="text-2xl font-bold">{{ formatCost(todayCost.total_usd) }}</p>
169
+ </div>
170
+ <div>
171
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
172
+ Calls
173
+ </p>
174
+ <p class="text-2xl font-bold">{{ todayCost.call_count }}</p>
175
+ </div>
176
+ <div>
177
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
178
+ Cache
179
+ </p>
180
+ <p class="text-2xl font-bold">
181
+ {{ (todayCost.cache_hit_rate * 100).toFixed(0) }}%
182
+ </p>
183
+ </div>
92
184
  </div>
93
- <div class="text-center">
94
- <p class="text-2xl font-semibold text-muted">{{ overview?.tasks?.queued ?? 0 }}</p>
95
- <p class="text-xs text-muted">Queued</p>
185
+ </div>
186
+ </UCard>
187
+
188
+ <!-- Two columns: projects + incidents -->
189
+ <div class="grid grid-cols-1 lg:grid-cols-[2fr_1fr] gap-6">
190
+ <!-- Projects -->
191
+ <div>
192
+ <div class="flex items-baseline justify-between mb-3">
193
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-muted">
194
+ Projects
195
+ </h2>
196
+ <NuxtLink to="/settings" class="text-xs text-muted hover:text-primary">
197
+ Configure dirs →
198
+ </NuxtLink>
96
199
  </div>
200
+ <DashboardState
201
+ :status="status"
202
+ :empty="!data?.projects?.length"
203
+ empty-title="No projects discovered yet"
204
+ empty-description="Add your project directories in Settings → Projects."
205
+ empty-icon="i-lucide-folder-open"
206
+ >
207
+ <div class="space-y-2">
208
+ <div
209
+ v-for="p in data?.projects"
210
+ :key="p.name"
211
+ class="rounded-lg border border-default p-3 hover:border-primary/40 transition-colors"
212
+ >
213
+ <div class="flex items-start justify-between gap-3">
214
+ <div class="min-w-0 flex-1">
215
+ <div class="flex items-center gap-2 mb-1">
216
+ <span class="text-sm font-semibold truncate">{{ p.name }}</span>
217
+ <UBadge
218
+ v-if="p.status"
219
+ :label="p.status"
220
+ :color="statusColor(p.status)"
221
+ variant="subtle"
222
+ size="xs"
223
+ />
224
+ <UBadge
225
+ v-if="p.ecosystem"
226
+ :label="p.ecosystem"
227
+ color="primary"
228
+ variant="outline"
229
+ size="xs"
230
+ />
231
+ </div>
232
+ <p class="text-xs text-muted font-mono truncate">{{ p.path }}</p>
233
+ <div class="flex items-center gap-2 mt-2 flex-wrap">
234
+ <UBadge
235
+ v-for="s in p.stack"
236
+ :key="s"
237
+ :label="s"
238
+ variant="soft"
239
+ size="xs"
240
+ />
241
+ </div>
242
+ </div>
243
+ <span
244
+ class="text-xs font-mono shrink-0"
245
+ :class="commitFreshness(p.last_commit_days).color"
246
+ >
247
+ {{ commitFreshness(p.last_commit_days).label }}
248
+ </span>
249
+ </div>
250
+ </div>
251
+ </div>
252
+ </DashboardState>
97
253
  </div>
98
- </div>
99
254
 
100
- <!-- Knowledge Summary -->
101
- <div class="rounded-lg border border-default p-6">
102
- <h3 class="mb-4 text-lg font-semibold text-highlighted">Knowledge Base</h3>
103
- <div class="grid grid-cols-2 gap-4">
104
- <div class="text-center">
105
- <p class="text-2xl font-semibold text-highlighted">{{ overview?.knowledge?.total_chunks ?? 0 }}</p>
106
- <p class="text-xs text-muted">Chunks</p>
255
+ <!-- Incidents + Quick actions -->
256
+ <div class="space-y-6">
257
+ <div>
258
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-muted mb-3">
259
+ Recent incidents
260
+ </h2>
261
+ <DashboardState
262
+ :status="status"
263
+ :empty="!data?.recent_incidents?.length"
264
+ empty-title="No incidents"
265
+ empty-description="Bypass uses and flow blocks show up here."
266
+ empty-icon="i-lucide-shield-check"
267
+ >
268
+ <div class="space-y-2">
269
+ <div
270
+ v-for="(i, idx) in data?.recent_incidents"
271
+ :key="idx"
272
+ class="rounded-lg border border-default p-3"
273
+ >
274
+ <div class="flex items-center gap-2 mb-1">
275
+ <UBadge
276
+ :label="i.kind"
277
+ :color="i.kind === 'bypass' ? 'warning' : 'error'"
278
+ variant="subtle"
279
+ size="xs"
280
+ />
281
+ <span class="text-xs text-muted">{{ formatIncidentTs(i.ts) }}</span>
282
+ </div>
283
+ <p class="text-xs font-mono text-muted truncate" :title="i.reason">
284
+ {{ i.tool }} — {{ i.reason }}
285
+ </p>
286
+ </div>
287
+ </div>
288
+ </DashboardState>
107
289
  </div>
108
- <div class="text-center">
109
- <p class="text-2xl font-semibold text-highlighted">{{ overview?.knowledge?.total_files ?? 0 }}</p>
110
- <p class="text-xs text-muted">Files</p>
290
+
291
+ <div>
292
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-muted mb-3">
293
+ Quick actions
294
+ </h2>
295
+ <div class="space-y-1">
296
+ <button
297
+ v-for="a in data?.quick_actions"
298
+ :key="a.command"
299
+ type="button"
300
+ class="w-full text-left p-3 rounded-lg border border-default hover:border-primary/40 hover:bg-elevated/30 transition-colors"
301
+ @click="copyCommand(a.command)"
302
+ >
303
+ <div class="flex items-center gap-2">
304
+ <code class="text-sm font-mono font-semibold text-primary">{{ a.command }}</code>
305
+ <UIcon name="i-lucide-clipboard" class="size-3 text-muted ml-auto" />
306
+ </div>
307
+ <p class="text-xs text-muted mt-1">{{ a.description }}</p>
308
+ </button>
309
+ </div>
111
310
  </div>
112
311
  </div>
113
312
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "2.82.0",
3
+ "version": "2.83.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.82.0"
3
+ version = "2.83.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,176 @@ def persona_build(body: dict):
729
729
  }
730
730
 
731
731
 
732
+ # --- Command center (PR66 v2.83.0) ---
733
+
734
+ @app.get("/api/overview/command-center")
735
+ def overview_command_center():
736
+ """Telemetry-driven overview surfacing what the operator actually needs.
737
+
738
+ Returns greeting, today's cost, project list with stack + last-commit
739
+ + status, recent enforcement incidents, and suggested quick actions.
740
+ """
741
+ from core.profile import ProfileManager
742
+ from core.profile.manager import parse_projects_dirs
743
+ from core.runtime.llm_cost_telemetry import summarise
744
+
745
+ profile = ProfileManager().read()
746
+ today_cost = summarise(period="today")
747
+
748
+ return {
749
+ "greeting": {
750
+ "name": profile.name,
751
+ "role": profile.role,
752
+ "company": profile.company,
753
+ "language": profile.language,
754
+ },
755
+ "today_cost": {
756
+ "total_usd": today_cost.total_cost_usd,
757
+ "call_count": today_cost.call_count,
758
+ "tokens_in": today_cost.total_tokens_in,
759
+ "tokens_out": today_cost.total_tokens_out,
760
+ "cache_hit_rate": today_cost.cache_hit_rate,
761
+ },
762
+ "projects": _scan_projects(parse_projects_dirs(profile.projectsDir)),
763
+ "recent_incidents": _recent_incidents(limit=8),
764
+ "quick_actions": [
765
+ {"command": "/arka update", "description": "Sync projects + skills"},
766
+ {"command": "/arka costs", "description": "View detailed LLM cost breakdown"},
767
+ {"command": "/arka conclave", "description": "Convene the personal AI advisory board"},
768
+ {"command": "/dev review", "description": "Run a code review on the current branch"},
769
+ ],
770
+ }
771
+
772
+
773
+ def _scan_projects(projects_dirs: list[str]) -> list[dict]:
774
+ """Read each project descriptor and enrich with last-commit info.
775
+
776
+ Best-effort: never raises. Returns an empty list when descriptors
777
+ or scan directories are missing.
778
+ """
779
+ from datetime import datetime, timezone
780
+ descriptor_dir = Path.home() / ".arkaos" / "projects"
781
+ if not descriptor_dir.exists():
782
+ return []
783
+
784
+ rows: list[dict] = []
785
+ for entry in sorted(descriptor_dir.iterdir()):
786
+ if entry.is_dir():
787
+ descriptor = entry / "PROJECT.md"
788
+ elif entry.suffix == ".md":
789
+ descriptor = entry
790
+ else:
791
+ continue
792
+ if not descriptor.exists():
793
+ continue
794
+ try:
795
+ data = _parse_descriptor(descriptor)
796
+ except Exception:
797
+ continue
798
+ rows.append(data)
799
+
800
+ # Sort by last_commit_days ascending (most recently active first).
801
+ rows.sort(key=lambda r: (
802
+ r.get("last_commit_days") if r.get("last_commit_days") is not None else 9999,
803
+ r.get("name", ""),
804
+ ))
805
+ return rows[:30] # cap to keep payload bounded
806
+
807
+
808
+ def _parse_descriptor(path: Path) -> dict:
809
+ """Extract frontmatter + last-commit-days from a project descriptor."""
810
+ text = path.read_text(encoding="utf-8", errors="replace")
811
+ fm: dict = {}
812
+ if text.startswith("---\n"):
813
+ end = text.find("\n---", 4)
814
+ if end > 0:
815
+ import yaml as _yaml
816
+ try:
817
+ fm = _yaml.safe_load(text[4:end]) or {}
818
+ except Exception:
819
+ fm = {}
820
+ name = str(fm.get("name") or path.stem)
821
+ project_path = str(fm.get("path") or "")
822
+ stack = fm.get("stack") or []
823
+ if not isinstance(stack, list):
824
+ stack = [str(stack)]
825
+ status = str(fm.get("status") or "unknown")
826
+ ecosystem = str(fm.get("ecosystem") or "")
827
+ last_commit_days = _last_commit_days(project_path) if project_path else None
828
+ return {
829
+ "name": name,
830
+ "path": project_path,
831
+ "stack": [str(s) for s in stack][:6],
832
+ "status": status,
833
+ "ecosystem": ecosystem,
834
+ "last_commit_days": last_commit_days,
835
+ }
836
+
837
+
838
+ def _last_commit_days(project_path: str) -> Optional[int]:
839
+ """Return days since the last git commit, or None when unknown."""
840
+ import os
841
+ if not project_path or not os.path.isdir(project_path):
842
+ return None
843
+ git_dir = os.path.join(project_path, ".git")
844
+ if not os.path.exists(git_dir):
845
+ return None
846
+ try:
847
+ from datetime import datetime, timezone
848
+ result = subprocess.run(
849
+ ["git", "-C", project_path, "log", "-1", "--format=%ct"],
850
+ capture_output=True, text=True, timeout=3, check=False,
851
+ )
852
+ if result.returncode != 0 or not result.stdout.strip():
853
+ return None
854
+ committed_at = datetime.fromtimestamp(int(result.stdout.strip()), tz=timezone.utc)
855
+ delta = datetime.now(timezone.utc) - committed_at
856
+ return max(0, delta.days)
857
+ except (OSError, ValueError, subprocess.TimeoutExpired):
858
+ return None
859
+
860
+
861
+ def _recent_incidents(limit: int = 8) -> list[dict]:
862
+ """Recent enforcement / bypass events from telemetry.
863
+
864
+ Reads the tail of ~/.arkaos/telemetry/enforcement.jsonl and keeps
865
+ rows where the operator hit a bypass or a flow-marker block. The
866
+ UI uses these to show "what went sideways recently".
867
+ """
868
+ log = Path.home() / ".arkaos" / "telemetry" / "enforcement.jsonl"
869
+ if not log.exists():
870
+ return []
871
+ try:
872
+ text = log.read_text(encoding="utf-8", errors="replace")
873
+ except OSError:
874
+ return []
875
+ rows: list[dict] = []
876
+ # Walk lines in reverse; stop when we've gathered `limit` matches.
877
+ for line in reversed(text.splitlines()):
878
+ if len(rows) >= limit:
879
+ break
880
+ if not line.strip():
881
+ continue
882
+ try:
883
+ entry = json.loads(line)
884
+ except json.JSONDecodeError:
885
+ continue
886
+ # Interesting events: bypass used OR allow=False (a block).
887
+ bypass = bool(entry.get("bypass_used"))
888
+ allowed = entry.get("allow")
889
+ if not bypass and allowed is not False:
890
+ continue
891
+ rows.append({
892
+ "ts": entry.get("ts", ""),
893
+ "tool": entry.get("tool", ""),
894
+ "reason": entry.get("reason", ""),
895
+ "cwd": entry.get("cwd", ""),
896
+ "bypass_used": bypass,
897
+ "kind": "bypass" if bypass else "blocked",
898
+ })
899
+ return rows
900
+
901
+
732
902
  # --- LLM Costs (PR65 v2.82.0) ---
733
903
 
734
904
  @app.get("/api/llm-costs")