[eric] [eric] add PostHog analytics, 9Router subscription proxy, unified usage tracking

- PostHog integration: collector, analytics subapp, opt-in UI, Analytics page
  - 9Router: auto-start, OAuth subscription flow, /v1/messages Anthropic format support
  - Settings overhaul: multi-provider API keys, subscription connect UI, onboarding modal
  - Unified usage: merge 9Router cost/token data into Settings Usage tab
  - Provider system: providers/, agent_loop, tools/ (unused, for future non-Anthropic support)
  - Agent SDK: restored as primary with 9Router ANTHROPIC_BASE_URL fallback
  - Updated system prompt, credential resolution, dashboard analytics
This commit is contained in:
ciregenz
2026-03-24 14:02:58 -07:00
parent 8d09e46df5
commit ebea25f0c2
38 changed files with 4382 additions and 373 deletions
+1
View File
@@ -3,3 +3,4 @@ const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai';
+17 -4
View File
@@ -50,6 +50,7 @@ export interface AgentSession {
id: string;
name: string;
status: 'draft' | 'running' | 'waiting_approval' | 'completed' | 'error' | 'stopped';
provider: string;
model: string;
mode: string;
worktree_path: string | null;
@@ -75,6 +76,7 @@ export interface AgentSession {
export interface AgentConfig {
name?: string;
provider?: string;
model?: string;
mode?: string;
system_prompt?: string;
@@ -151,6 +153,7 @@ export interface SendMessagePayload {
prompt: string;
mode?: string;
model?: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
@@ -161,11 +164,11 @@ export interface SendMessagePayload {
export const sendMessage = createAsyncThunk(
'agents/sendMessage',
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
});
return { sessionId, prompt };
}
@@ -213,6 +216,7 @@ export interface LaunchAndSendPayload {
prompt: string;
mode: string;
model: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
@@ -232,7 +236,7 @@ export const fetchSession = createAsyncThunk(
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -244,7 +248,7 @@ export const launchAndSendFirstMessage = createAsyncThunk(
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
});
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
@@ -424,6 +428,7 @@ const agentsSlice = createSlice({
id: draftId,
name: 'New chat',
status: 'draft',
provider: 'anthropic',
model: 'sonnet',
mode,
worktree_path: null,
@@ -664,6 +669,13 @@ const agentsSlice = createSlice({
}
},
updateSessionProvider(state, action: PayloadAction<{ sessionId: string; provider: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.provider = action.payload.provider;
}
},
updateSessionModel(state, action: PayloadAction<{ sessionId: string; model: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -1004,6 +1016,7 @@ export const {
updateSessionCost,
addBranch,
setActiveBranch,
updateSessionProvider,
updateSessionModel,
updateSessionMode,
closeSessionFromWs,
+37 -190
View File
@@ -3,208 +3,64 @@ import { API_BASE } from '@/shared/config';
const ANALYTICS_API = `${API_BASE}/analytics`;
export interface AnalyticsSummary {
export interface UsageSummary {
total_sessions: number;
total_cost_usd: number;
total_messages: number;
total_tool_calls: number;
avg_session_duration_seconds: number;
session_completion_rate: number;
approval_rate: number;
models_used: Record<string, number>;
modes_used: Record<string, number>;
top_tools: [string, number][];
}
export interface UsagePoint {
date: string;
sessions: number;
cost: number;
}
export interface CostPoint {
date: string;
cost: number;
}
export interface ToolRank {
tool: string;
count: number;
}
export interface ApprovalStats {
allow: number;
deny: number;
total: number;
rate: number;
avg_latency_ms: number;
}
export interface SessionStats {
completed: number;
stopped: number;
error: number;
total: number;
completion_rate: number;
avg_duration_seconds: number;
avg_cost_per_session: number;
completion_rate: number;
models_used: Record<string, number>;
providers_used: Record<string, number>;
top_tools: Record<string, number>;
status_breakdown: Record<string, number>;
// 9Router enrichment
total_prompt_tokens: number;
total_completion_tokens: number;
cost_by_model: Record<string, { cost: number; requests: number; prompt_tokens: number; completion_tokens: number }>;
cost_by_provider: Record<string, { cost: number; requests: number }>;
cost_source: ' 9router' | 'sdk' | 'none';
nine_router_available: boolean;
total_requests: number;
}
export interface HourlyPoint {
hour: number;
count: number;
}
export interface DurationBucket {
label: string;
count: number;
}
export interface CostByModel {
model: string;
cost: number;
sessions: number;
}
export interface CumulativeCostPoint {
date: string;
cumulative: number;
daily: number;
}
export interface ToolDuration {
tool: string;
calls: number;
avg_ms: number;
max_ms: number;
}
export interface SessionCost {
timestamp: string;
model: string;
cost: number;
duration: number;
messages: number;
export interface CostBreakdown {
available: boolean;
period: string;
total_cost: number;
total_requests: number;
total_prompt_tokens: number;
total_completion_tokens: number;
by_model: Record<string, any>;
by_provider: Record<string, any>;
}
interface AnalyticsState {
summary: AnalyticsSummary | null;
usage: UsagePoint[];
cost: CostPoint[];
tools: ToolRank[];
approvals: ApprovalStats | null;
sessionStats: SessionStats | null;
hourly: HourlyPoint[];
durationDist: DurationBucket[];
costByModel: CostByModel[];
cumulativeCost: CumulativeCostPoint[];
toolDurations: ToolDuration[];
sessionCosts: SessionCost[];
exportPreview: any | null;
summary: UsageSummary | null;
costBreakdown: CostBreakdown | null;
loading: boolean;
}
const initialState: AnalyticsState = {
summary: null,
usage: [],
cost: [],
tools: [],
approvals: null,
sessionStats: null,
hourly: [],
durationDist: [],
costByModel: [],
cumulativeCost: [],
toolDurations: [],
sessionCosts: [],
exportPreview: null,
costBreakdown: null,
loading: false,
};
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
const res = await fetch(`${ANALYTICS_API}/summary`);
return (await res.json()) as AnalyticsSummary;
const res = await fetch(`${ANALYTICS_API}/usage-summary`);
return (await res.json()) as UsageSummary;
});
export const fetchUsage = createAsyncThunk(
'analytics/fetchUsage',
async ({ period, range }: { period: string; range: number }) => {
const res = await fetch(`${ANALYTICS_API}/usage?period=${period}&range=${range}`);
const data = await res.json();
return data.data as UsagePoint[];
export const fetchCostBreakdown = createAsyncThunk(
'analytics/fetchCostBreakdown',
async (period: string = '7d') => {
const res = await fetch(`${ANALYTICS_API}/cost-breakdown?period=${period}`);
return (await res.json()) as CostBreakdown;
},
);
export const fetchCost = createAsyncThunk(
'analytics/fetchCost',
async ({ period, range }: { period: string; range: number }) => {
const res = await fetch(`${ANALYTICS_API}/cost?period=${period}&range=${range}`);
const data = await res.json();
return data.data as CostPoint[];
},
);
export const fetchTools = createAsyncThunk('analytics/fetchTools', async () => {
const res = await fetch(`${ANALYTICS_API}/tools?limit=20`);
const data = await res.json();
return data.data as ToolRank[];
});
export const fetchApprovals = createAsyncThunk('analytics/fetchApprovals', async () => {
const res = await fetch(`${ANALYTICS_API}/approvals`);
return (await res.json()) as ApprovalStats;
});
export const fetchSessionStats = createAsyncThunk('analytics/fetchSessionStats', async () => {
const res = await fetch(`${ANALYTICS_API}/sessions-stats`);
return (await res.json()) as SessionStats;
});
export const fetchHourlyActivity = createAsyncThunk('analytics/fetchHourly', async () => {
const res = await fetch(`${ANALYTICS_API}/hourly-activity`);
const data = await res.json();
return data.data as HourlyPoint[];
});
export const fetchDurationDistribution = createAsyncThunk('analytics/fetchDurationDist', async () => {
const res = await fetch(`${ANALYTICS_API}/duration-distribution`);
const data = await res.json();
return data.data as DurationBucket[];
});
export const fetchCostByModel = createAsyncThunk('analytics/fetchCostByModel', async () => {
const res = await fetch(`${ANALYTICS_API}/cost-by-model`);
const data = await res.json();
return data.data as CostByModel[];
});
export const fetchCumulativeCost = createAsyncThunk('analytics/fetchCumulativeCost', async () => {
const res = await fetch(`${ANALYTICS_API}/cumulative-cost?range=90`);
const data = await res.json();
return data.data as CumulativeCostPoint[];
});
export const fetchToolDurations = createAsyncThunk('analytics/fetchToolDurations', async () => {
const res = await fetch(`${ANALYTICS_API}/tool-durations`);
const data = await res.json();
return data.data as ToolDuration[];
});
export const fetchSessionCosts = createAsyncThunk('analytics/fetchSessionCosts', async () => {
const res = await fetch(`${ANALYTICS_API}/cost-per-session?limit=50`);
const data = await res.json();
return data.data as SessionCost[];
});
export const fetchExportPreview = createAsyncThunk('analytics/fetchExportPreview', async () => {
const res = await fetch(`${ANALYTICS_API}/export/preview`);
return await res.json();
});
export const doExport = createAsyncThunk('analytics/doExport', async () => {
const res = await fetch(`${ANALYTICS_API}/export`, { method: 'POST' });
return await res.json();
});
const analyticsSlice = createSlice({
name: 'analytics',
initialState,
@@ -217,18 +73,9 @@ const analyticsSlice = createSlice({
state.summary = action.payload;
})
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
.addCase(fetchUsage.fulfilled, (state, action) => { state.usage = action.payload; })
.addCase(fetchCost.fulfilled, (state, action) => { state.cost = action.payload; })
.addCase(fetchTools.fulfilled, (state, action) => { state.tools = action.payload; })
.addCase(fetchApprovals.fulfilled, (state, action) => { state.approvals = action.payload; })
.addCase(fetchSessionStats.fulfilled, (state, action) => { state.sessionStats = action.payload; })
.addCase(fetchHourlyActivity.fulfilled, (state, action) => { state.hourly = action.payload; })
.addCase(fetchDurationDistribution.fulfilled, (state, action) => { state.durationDist = action.payload; })
.addCase(fetchCostByModel.fulfilled, (state, action) => { state.costByModel = action.payload; })
.addCase(fetchCumulativeCost.fulfilled, (state, action) => { state.cumulativeCost = action.payload; })
.addCase(fetchToolDurations.fulfilled, (state, action) => { state.toolDurations = action.payload; })
.addCase(fetchSessionCosts.fulfilled, (state, action) => { state.sessionCosts = action.payload; })
.addCase(fetchExportPreview.fulfilled, (state, action) => { state.exportPreview = action.payload; });
.addCase(fetchCostBreakdown.fulfilled, (state, action) => {
state.costBreakdown = action.payload;
});
},
});
+49
View File
@@ -0,0 +1,49 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const AGENTS_API = `${API_BASE}/agents`;
export interface ModelOption {
value: string;
label: string;
version?: string;
context_window: number;
}
interface ModelsState {
byProvider: Record<string, ModelOption[]>;
loaded: boolean;
}
const initialState: ModelsState = {
byProvider: {},
loaded: false,
};
export const fetchModels = createAsyncThunk('models/fetchModels', async () => {
const res = await fetch(`${AGENTS_API}/models`);
if (!res.ok) throw new Error('Failed to fetch models');
const data = await res.json();
// API returns { models: { provider: [...] } }
const models = data.models || data;
return models as Record<string, ModelOption[]>;
});
const modelsSlice = createSlice({
name: 'models',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchModels.fulfilled, (state, action) => {
state.byProvider = action.payload;
state.loaded = true;
})
.addCase(fetchModels.rejected, (state) => {
// Mark as loaded even on failure so we fall back to hardcoded options
state.loaded = true;
});
},
});
export default modelsSlice.reducer;
@@ -11,6 +11,13 @@ export const DEFAULT_SYSTEM_PROMPT =
`If a Browser is selected, prioritize this over other tools when it makes sense (so the user also has observability).\n\n` +
`If multiple Browsers are selected, parallelize the tasks across them.`;
export interface CustomProvider {
name: string;
base_url: string;
api_key: string;
models: Array<{ value: string; label: string; context_window?: number }>;
}
export interface AppSettings {
default_system_prompt: string | null;
default_folder: string | null;
@@ -21,6 +28,10 @@ export interface AppSettings {
theme: 'light' | 'dark';
new_agent_shortcut: string;
anthropic_api_key: string | null;
openai_api_key?: string | null;
google_api_key?: string | null;
openrouter_api_key?: string | null;
custom_providers?: CustomProvider[];
browser_homepage: string;
auto_select_mode_on_new_agent: boolean;
expand_new_chats_in_dashboard: boolean;
+2
View File
@@ -13,6 +13,7 @@ import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
import analyticsReducer from './analyticsSlice';
import modelsReducer from './modelsSlice';
export const store = configureStore({
reducer: {
@@ -30,6 +31,7 @@ export const store = configureStore({
dashboards: dashboardsReducer,
update: updateReducer,
analytics: analyticsReducer,
models: modelsReducer,
},
});
+1 -1
View File
@@ -284,7 +284,7 @@ class WebSocketManager {
sendMessage(
sessionId: string,
prompt: string,
opts?: { mode?: string; model?: string; images?: Array<{ data: string; media_type: string }> },
opts?: { mode?: string; model?: string; provider?: string; images?: Array<{ data: string; media_type: string }> },
) {
this.send('agent:send_message', {
session_id: sessionId,