Skip to content
35 changes: 35 additions & 0 deletions galaxy/core/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class EventType(Enum):
)
DEVICE_STATUS_CHANGED = "device_status_changed" # Device status changed

# LLM cost/token tracking events
LLM_CALL_COMPLETED = "llm_call_completed" # LLM API call finished
COST_THRESHOLD_EXCEEDED = "cost_threshold_exceeded" # Session cost exceeded configured threshold


@dataclass
class Event:
Expand Down Expand Up @@ -120,6 +124,37 @@ class DeviceEvent(Event):
all_devices: Dict[str, Dict[str, Any]] # Snapshot of all devices in registry


@dataclass
class LLMCallEvent(Event):
"""
LLM API call completed event.

Extends base Event class with LLM-specific cost and token usage
information for tracking and aggregation.
"""

agent_type: str # e.g. "HOST_AGENT", "CONSTELLATION_AGENT"
model: str # e.g. "gpt-4o", "claude-3-5-sonnet-20241022"
prompt_tokens: int
completion_tokens: int
cost: float
duration_ms: float # wall time of the API call


@dataclass
class CostThresholdExceededEvent(Event):
"""
Session cost threshold exceeded event.

Published when the accumulated session cost crosses the configured
cost_alert_threshold, allowing observers to surface alerts.
"""

session_id: str
total_cost: float
threshold: float


class IEventObserver(ABC):
"""
Interface for event observers.
Expand Down
87 changes: 85 additions & 2 deletions galaxy/session/observers/base_observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@
from ...agents.constellation_agent import ConstellationAgent
from ...core.events import (
ConstellationEvent,
CostThresholdExceededEvent,
Event,
EventType,
IEventObserver,
LLMCallEvent,
TaskEvent,
)
from ...visualization.change_detector import VisualizationChangeDetector
Expand Down Expand Up @@ -107,12 +109,21 @@ class SessionMetricsObserver(IEventObserver):
Observer that collects session metrics and statistics.
"""

def __init__(self, session_id: str, logger: Optional[logging.Logger] = None):
_LLM_CALLS_CAP = 500

def __init__(
self,
session_id: str,
logger: Optional[logging.Logger] = None,
cost_alert_threshold: float = 0.0,
):
"""
Initialize SessionMetricsObserver.

:param session_id: Unique session identifier for metrics tracking
:param logger: Optional logger instance (creates default if None)
:param cost_alert_threshold: Emit CostThresholdExceededEvent when total
cost exceeds this value. ``0.0`` (default) disables the check.
"""
self.metrics: Dict[str, Any] = {
"session_id": session_id,
Expand All @@ -127,7 +138,18 @@ def __init__(self, session_id: str, logger: Optional[logging.Logger] = None):
"total_constellation_time": 0.0,
"constellation_timings": {},
"constellation_modifications": {}, # Track modifications per constellation
"llm_metrics": {
"total_cost": 0.0,
"total_prompt_tokens": 0,
"total_completion_tokens": 0,
"total_api_calls": 0,
"cost_by_agent": {}, # agent_type -> float
"cost_by_model": {}, # model_name -> float
"calls": [], # list of LLMCallEvent data (capped at last 500)
},
}
self._cost_alert_threshold = cost_alert_threshold
self._threshold_already_exceeded = False
self.logger = logger or logging.getLogger(__name__)

async def on_event(self, event: Event) -> None:
Expand All @@ -136,7 +158,9 @@ async def on_event(self, event: Event) -> None:

:param event: Event instance for metrics collection
"""
if isinstance(event, TaskEvent):
if isinstance(event, LLMCallEvent):
await self._handle_llm_call_event(event)
elif isinstance(event, TaskEvent):
await self._handle_task_event(event)
elif isinstance(event, ConstellationEvent):
await self._handle_constellation_event(event)
Expand Down Expand Up @@ -304,6 +328,65 @@ def _handle_constellation_modified(self, event: ConstellationEvent) -> None:
modification_record
)

async def _handle_llm_call_event(self, event: LLMCallEvent) -> None:
"""
Handle LLM_CALL_COMPLETED event — update llm_metrics aggregates.

:param event: LLMCallEvent instance
"""
llm = self.metrics["llm_metrics"]

# Update totals
llm["total_cost"] += event.cost
llm["total_prompt_tokens"] += event.prompt_tokens
llm["total_completion_tokens"] += event.completion_tokens
llm["total_api_calls"] += 1

# Per-agent and per-model breakdowns
llm["cost_by_agent"][event.agent_type] = (
llm["cost_by_agent"].get(event.agent_type, 0.0) + event.cost
)
llm["cost_by_model"][event.model] = (
llm["cost_by_model"].get(event.model, 0.0) + event.cost
)

# Append call record (cap at _LLM_CALLS_CAP)
call_record = {
"agent_type": event.agent_type,
"model": event.model,
"prompt_tokens": event.prompt_tokens,
"completion_tokens": event.completion_tokens,
"cost": event.cost,
"duration_ms": event.duration_ms,
"timestamp": event.timestamp,
}
calls = llm["calls"]
calls.append(call_record)
if len(calls) > self._LLM_CALLS_CAP:
llm["calls"] = calls[-self._LLM_CALLS_CAP :]

# Cost threshold alerting
if (
self._cost_alert_threshold > 0
and llm["total_cost"] > self._cost_alert_threshold
and not self._threshold_already_exceeded
):
self._threshold_already_exceeded = True
self.logger.warning(
"Session %s exceeded cost threshold: $%.4f",
self.metrics["session_id"],
llm["total_cost"],
)
from ...core.events import get_event_bus

threshold_event = CostThresholdExceededEvent(
event_type=EventType.COST_THRESHOLD_EXCEEDED,
session_id=self.metrics["session_id"],
total_cost=llm["total_cost"],
threshold=self._cost_alert_threshold,
)
await get_event_bus().publish_event(threshold_event)

def get_metrics(self) -> Dict[str, Any]:
"""
Get collected metrics with computed statistics.
Expand Down
50 changes: 47 additions & 3 deletions galaxy/webui/frontend/src/components/layout/RightPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useEffect, useMemo } from 'react';
import { shallow } from 'zustand/shallow';
import clsx from 'clsx';
import { Network, Star } from 'lucide-react';
import { Network, Star, DollarSign } from 'lucide-react';
import ConstellationBlock from '../constellation/ConstellationBlock';
import TaskList from '../tasks/TaskList';
import TaskDetailPanel from '../tasks/TaskDetailPanel';
import CostDashboard from '../metrics/CostDashboard';
import { ConstellationSummary, Task, useGalaxyStore } from '../../store/galaxyStore';

const statusColors: Record<string, string> = {
Expand All @@ -22,13 +23,15 @@ const RightPanel: React.FC = () => {
ui,
setActiveConstellation,
setActiveTask,
setRightPanelTab,
} = useGalaxyStore(
(state) => ({
constellations: state.constellations,
tasks: state.tasks,
ui: state.ui,
setActiveConstellation: state.setActiveConstellation,
setActiveTask: state.setActiveTask,
setRightPanelTab: state.setRightPanelTab,
}),
shallow,
);
Expand Down Expand Up @@ -77,9 +80,47 @@ const RightPanel: React.FC = () => {
setActiveConstellation(selected || null);
};

const isCostView = ui.rightPanelTab === 'cost';

return (
<div className="flex h-full w-full flex-col gap-3">
{/* Panel tab bar */}
<div className="flex shrink-0 gap-1 rounded-full border border-white/10 bg-black/20 p-1">
<button
onClick={() => setRightPanelTab('constellation')}
className={clsx(
'flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition',
!isCostView
? 'bg-white/10 text-white shadow-inner'
: 'text-slate-400 hover:text-slate-200',
)}
>
<Network className="h-3.5 w-3.5" aria-hidden />
Constellation
</button>
<button
onClick={() => setRightPanelTab('cost')}
className={clsx(
'flex flex-1 items-center justify-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition',
isCostView
? 'bg-white/10 text-white shadow-inner'
: 'text-slate-400 hover:text-slate-200',
)}
>
<DollarSign className="h-3.5 w-3.5" aria-hidden />
Cost
</button>
</div>

{/* Cost dashboard */}
{isCostView && (
<div className="flex flex-1 min-h-0 flex-col overflow-y-auto rounded-[28px] border border-white/10 bg-gradient-to-br from-[rgba(11,30,45,0.88)] via-[rgba(8,20,35,0.85)] to-[rgba(6,15,28,0.88)] p-4 shadow-[0_8px_32px_rgba(0,0,0,0.4),inset_0_1px_1px_rgba(255,255,255,0.08)] ring-1 ring-inset ring-white/5">
<CostDashboard />
</div>
)}

{/* Constellation Overview - Top half */}
{!isCostView && (
<div className="flex flex-1 min-h-0 flex-col gap-3 rounded-[28px] border border-white/10 bg-gradient-to-br from-[rgba(11,30,45,0.88)] via-[rgba(8,20,35,0.85)] to-[rgba(6,15,28,0.88)] p-4 overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.4),0_2px_8px_rgba(147,51,234,0.12),inset_0_1px_1px_rgba(255,255,255,0.08)] ring-1 ring-inset ring-white/5">
<div className="flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3">
Expand Down Expand Up @@ -116,12 +157,14 @@ const RightPanel: React.FC = () => {
/>
</div>
</div>
)}

{/* TaskStar List or Task Detail - Bottom half */}
{!isCostView && (
<div className="flex flex-1 min-h-0 flex-col gap-3 rounded-[28px] border border-white/10 bg-gradient-to-br from-[rgba(11,30,45,0.88)] via-[rgba(8,20,35,0.85)] to-[rgba(6,15,28,0.88)] p-4 overflow-hidden shadow-[0_8px_32px_rgba(0,0,0,0.4),0_2px_8px_rgba(6,182,212,0.12),inset_0_1px_1px_rgba(255,255,255,0.08)] ring-1 ring-inset ring-white/5">
{activeTask ? (
<TaskDetailPanel
task={activeTask}
<TaskDetailPanel
task={activeTask}
onBack={() => setActiveTask(null)}
/>
) : (
Expand All @@ -142,6 +185,7 @@ const RightPanel: React.FC = () => {
</>
)}
</div>
)}
</div>
);
};
Expand Down
51 changes: 51 additions & 0 deletions galaxy/webui/frontend/src/components/metrics/CostByModelChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';

interface BarChartProps {
data: Record<string, number>;
label: string;
colorClass?: string;
}

/**
* Horizontal bar chart rendered with pure Tailwind CSS.
* Used to show cost broken down by a string key (model name, agent type, etc.).
*/
const CostByModelChart: React.FC<BarChartProps> = ({
data,
label,
colorClass = 'bg-cyan-500',
}) => {
const entries = Object.entries(data).sort(([, a], [, b]) => b - a);
const max = entries.length > 0 ? entries[0][1] : 0;

if (entries.length === 0) {
return (
<div className="text-xs text-slate-500 py-2">No data yet</div>
);
}

return (
<div className="space-y-2">
<div className="text-[10px] uppercase tracking-[0.2em] text-slate-400 mb-1">{label}</div>
{entries.map(([key, value]) => {
const pct = max > 0 ? (value / max) * 100 : 0;
return (
<div key={key} className="flex items-center gap-2 text-xs">
<div className="w-28 shrink-0 truncate text-slate-300" title={key}>{key}</div>
<div className="flex-1 rounded-full bg-white/5 h-2 overflow-hidden">
<div
className={`h-full rounded-full ${colorClass} transition-all duration-500`}
style={{ width: `${pct}%` }}
/>
</div>
<div className="w-16 text-right font-mono text-slate-300">
${value.toFixed(4)}
</div>
</div>
);
})}
</div>
);
};

export default CostByModelChart;
Loading