[eric] fix 9Router CLI auth for subscription users, add analytics, update models

This commit is contained in:
ciregenz
2026-03-27 00:21:19 -07:00
parent f51c131bce
commit 7c3da1ab4c
14 changed files with 308 additions and 32 deletions
+113
View File
@@ -539,6 +539,12 @@ class AgentManager:
session.pending_approvals.append(approval_req)
session.status = "waiting_approval"
_analytics("approval.requested", {
"tool_name": tool_name,
"is_first_approval_in_session": len(session.pending_approvals) == 1,
"model": session.model,
}, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "waiting_approval",
@@ -548,6 +554,15 @@ class AgentManager:
session_id, request_id, tool_name, safe_input
)
approval_latency_ms = int((datetime.now() - approval_req.created_at).total_seconds() * 1000)
_analytics("approval.resolved", {
"tool_name": tool_name,
"decision": decision.get("behavior", "unknown"),
"latency_ms": approval_latency_ms,
"input_was_modified": decision.get("updated_input") is not None,
"model": session.model,
}, session_id=session_id, dashboard_id=session.dashboard_id)
session.pending_approvals = [
a for a in session.pending_approvals if a.id != request_id
]
@@ -625,6 +640,18 @@ class AgentManager:
raw_response = input_data.get("tool_response", "")
# Track individual tool execution
hook_tool_name_early = input_data.get("tool_name", "")
if hook_tool_name_early:
_analytics("tool.executed", {
"tool_name": hook_tool_name_early,
"tool_type": "mcp" if "__" in hook_tool_name_early else "builtin",
"duration_ms": elapsed_ms,
"success": not (isinstance(raw_response, str) and raw_response.startswith("Error")),
"model": session.model,
"provider": session.provider,
}, session_id=session_id, dashboard_id=session.dashboard_id)
if isinstance(raw_response, list) and raw_response:
text_parts = [
block.get("text", "")
@@ -839,8 +866,11 @@ class AgentManager:
from backend.apps.nine_router import is_running as _9r_running
if _9r_running():
options_kwargs["env"] = {
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128",
}
# --bare skips CLI's own OAuth/keychain auth, uses only ANTHROPIC_API_KEY
options_kwargs["extra_args"] = {"bare": None}
elif global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
else:
@@ -871,6 +901,7 @@ class AgentManager:
stream_text_msg_id = None
stream_tool_msg_ids_ordered = []
stream_block_index_map = {}
_turn_number = 0
async for message in query(
prompt=prompt_stream(),
@@ -976,6 +1007,13 @@ class AgentManager:
"message": tool_msg.model_dump(mode="json"),
})
_turn_number += 1
_analytics("turn.completed", {
"turn_number": _turn_number,
"tool_calls_in_turn": len(tool_uses),
"model": session.model,
}, session_id=session_id, dashboard_id=session.dashboard_id)
stream_text_msg_id = None
stream_tool_msg_ids_ordered = []
stream_block_index_map = {}
@@ -996,6 +1034,13 @@ class AgentManager:
except Exception as e:
logger.exception(f"Agent {session_id} error: {e}")
session.status = "error"
_analytics("session.error", {
"error_type": type(e).__name__,
"error_message": str(e)[:500],
"model": session.model,
"provider": session.provider,
"mode": session.mode,
}, session_id=session_id, dashboard_id=session.dashboard_id)
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -1026,6 +1071,12 @@ class AgentManager:
"tools_list": list(set(tool_names)),
"session_title": session.name,
"first_user_message": user_messages[0] if user_messages else "",
"input_tokens": session.tokens.get("input", 0),
"output_tokens": session.tokens.get("output", 0),
"is_sub_agent": session.parent_session_id is not None,
"parent_session_id": session.parent_session_id,
"sub_agent_count": len([s for s in self.sessions.values() if s.parent_session_id == session_id]),
"branch_count": len(session.branches),
}, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", {
@@ -1193,9 +1244,22 @@ class AgentManager:
session_changed = False
if model and model != session.model:
_analytics("model.switched", {
"from_model": session.model,
"to_model": model,
"from_provider": session.provider,
"to_provider": provider or session.provider,
"message_number": len([m for m in session.messages if m.role == "user"]),
"cost_so_far": session.cost_usd,
}, session_id=session_id, dashboard_id=session.dashboard_id)
session.model = model
session_changed = True
if mode and mode != session.mode:
_analytics("feature.used", {
"feature": "mode.switched",
"from_mode": session.mode,
"to_mode": mode,
}, session_id=session_id, dashboard_id=session.dashboard_id)
session.mode = mode
mode_tools, _, _ = self._resolve_mode(mode)
session.allowed_tools = mode_tools
@@ -1225,6 +1289,34 @@ class AgentManager:
"message": user_msg.model_dump(mode="json"),
})
# Track context attachment patterns
if context_paths or attached_skills or images or forced_tools:
_analytics("context.attached", {
"file_count": len([c for c in (context_paths or []) if c.get("type") == "file"]),
"directory_count": len([c for c in (context_paths or []) if c.get("type") == "directory"]),
"skill_count": len(attached_skills or []),
"image_count": len(images or []),
"has_forced_tools": bool(forced_tools),
}, session_id=session_id, dashboard_id=session.dashboard_id)
# Track skill usage
for skill in (attached_skills or []):
_analytics("feature.used", {
"feature": "skill.used",
"skill_name": skill.get("name", ""),
}, session_id=session_id, dashboard_id=session.dashboard_id)
# Track first message sophistication
is_first_message = sum(1 for m in session.messages if m.role == "user") == 1
if is_first_message:
_analytics("session.first_message", {
"message_length": len(prompt),
"has_code_block": "```" in prompt,
"has_url": "http://" in prompt or "https://" in prompt,
"model": session.model,
"mode": session.mode,
}, session_id=session_id, dashboard_id=session.dashboard_id)
session.status = "running"
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
@@ -1317,6 +1409,13 @@ class AgentManager:
session.branches[new_branch_id] = new_branch
session.active_branch_id = new_branch_id
_analytics("feature.used", {
"feature": "message.branched",
"branch_depth": len([b for b in session.branches.values() if b.parent_branch_id]),
"total_branches_in_session": len(session.branches),
"messages_before_fork": len([m for m in session.messages if m.branch_id == fork_parent_branch]),
}, session_id=session_id, dashboard_id=session.dashboard_id)
edited_msg = Message(
role="user",
content=new_content,
@@ -1592,6 +1691,20 @@ class AgentManager:
session = AgentSession(**data)
hours_since_closed = 0
if data.get("closed_at"):
try:
closed = datetime.fromisoformat(data["closed_at"][:19])
hours_since_closed = round((datetime.now() - closed).total_seconds() / 3600, 1)
except Exception:
pass
_analytics("session.resumed", {
"hours_since_closed": hours_since_closed,
"original_message_count": len(data.get("messages", [])),
"original_cost_usd": data.get("cost_usd", 0),
"model": session.model,
}, session_id=session_id, dashboard_id=session.dashboard_id)
session.closed_at = None
self.sessions[session_id] = session
+8
View File
@@ -239,6 +239,9 @@ async def subscriptions_poll(body: dict):
code_verifier=body.get("code_verifier"),
extra_data=body.get("extra_data"),
)
if result.get("success"):
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.connected", {"provider": provider})
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -259,6 +262,9 @@ async def subscriptions_exchange(body: dict):
try:
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state)
if result.get("success"):
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.connected", {"provider": provider})
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -290,6 +296,8 @@ async def subscriptions_disconnect(body: dict):
if conn and conn.get("id"):
async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete(f"{NINE_ROUTER_API}/providers/{conn['id']}")
from backend.apps.analytics.collector import record as _analytics
_analytics("subscription.disconnected", {"provider": provider})
return {"ok": True}
return {"ok": False, "error": "Connection not found"}
except Exception as e:
+7
View File
@@ -592,6 +592,13 @@ async def run_browser_agents(
Each task dict has: { browser_id (optional), task, url (optional) }
Returns a list of result dicts, one per task.
"""
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {
"feature": "browser_agent.launched",
"task_count": len(tasks),
"model": model,
}, dashboard_id=dashboard_id)
pre_selected = set(pre_selected_browser_ids or [])
async def _run_one(task_def: dict) -> dict:
+64 -2
View File
@@ -1,11 +1,13 @@
"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
import asyncio
import json
import logging
import os
import platform
from collections import Counter
from contextlib import asynccontextmanager
from datetime import datetime
from backend.config.Apps import SubApp
from backend.config.paths import SESSIONS_DIR
@@ -13,16 +15,49 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
logger = logging.getLogger(__name__)
APP_VERSION = "1.0.15"
_heartbeat_task: asyncio.Task | None = None
async def _heartbeat_loop():
"""Send a heartbeat event every 60 seconds for usage-time tracking."""
while True:
await asyncio.sleep(60)
try:
from backend.apps.agents.agent_manager import agent_manager
record("app.heartbeat", {
"active_session_count": len(agent_manager.sessions),
})
except Exception:
pass
@asynccontextmanager
async def analytics_lifespan():
global _heartbeat_task
init_collector()
logger.info("PostHog analytics initialised")
try:
from backend.apps.settings.settings import load_settings
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
# Track first open
is_first_open = settings.first_opened_at is None
if is_first_open:
settings.first_opened_at = datetime.now().isoformat()
_save_settings(settings)
days_since_install = 0
if settings.first_opened_at:
try:
first = datetime.fromisoformat(settings.first_opened_at[:19])
days_since_install = (datetime.now() - first).days
except Exception:
pass
providers = []
if getattr(settings, "anthropic_api_key", None):
providers.append("anthropic")
@@ -40,11 +75,15 @@ async def analytics_lifespan():
"platform": platform.platform(),
"provider_count": len(providers),
"providers": providers,
"is_first_open": is_first_open,
"days_since_install": days_since_install,
"app_version": APP_VERSION,
})
identify({
"providers_configured": providers,
"provider_count": len(providers),
"app_version": APP_VERSION,
})
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
@@ -56,8 +95,20 @@ async def analytics_lifespan():
except Exception as e:
logger.debug(f"9Router auto-start skipped: {e}")
# Start heartbeat
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
yield
# Stop heartbeat
if _heartbeat_task:
_heartbeat_task.cancel()
try:
await _heartbeat_task
except asyncio.CancelledError:
pass
_heartbeat_task = None
# Stop 9Router
try:
from backend.apps.nine_router import stop as stop_9router
@@ -123,7 +174,6 @@ async def usage_summary():
closed = s.get("closed_at")
if created and closed:
try:
from datetime import datetime
c_str = created[:19]
cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
@@ -230,3 +280,15 @@ async def cost_breakdown(period: str = "7d"):
@analytics.router.get("/status")
async def analytics_status():
return {"status": "posthog", "enabled": True}
@analytics.router.post("/event")
async def record_event(body: dict):
"""Accept analytics events from the frontend (e.g. feature.time_spent)."""
event_type = body.get("event_type", "")
properties = body.get("properties", {})
if event_type:
record(event_type, properties,
session_id=body.get("session_id"),
dashboard_id=body.get("dashboard_id"))
return {"ok": True}
+4
View File
@@ -305,6 +305,8 @@ async def create_output(body: OutputCreate):
updated_at=now,
)
_save(output)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "view.created"})
return {"ok": True, "output": output.model_dump()}
@@ -349,6 +351,8 @@ Return ONLY valid JSON with these keys. No markdown fences, no extra text.\
@outputs.router.post("/vibe-code")
async def vibe_code(body: VibeCodeRequest):
"""Use an LLM to generate or iterate on Output code from a natural language prompt."""
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "vibe_code.used"})
try:
import anthropic
except ImportError:
+1
View File
@@ -52,6 +52,7 @@ class AppSettings(BaseModel):
# Analytics: opted in by default, user can toggle off
analytics_opt_in: bool = True
installation_id: Optional[str] = None
first_opened_at: Optional[str] = None # ISO timestamp of first app open
class CustomProvider(BaseModel):
+33
View File
@@ -52,6 +52,39 @@ async def get_settings():
@settings.router.put("")
async def update_settings(body: AppSettings):
from backend.apps.analytics.collector import record as _analytics
old = load_settings()
# Track provider key changes
provider_keys = {
"anthropic_api_key": "anthropic",
"openai_api_key": "openai",
"google_api_key": "gemini",
"openrouter_api_key": "openrouter",
}
for key, provider_name in provider_keys.items():
old_val = bool(getattr(old, key, None))
new_val = bool(getattr(body, key, None))
if old_val != new_val:
_analytics("provider.configured", {
"provider": provider_name,
"action": "added" if new_val else "removed",
})
# Track settings changes (key names only, not values)
old_dict = old.model_dump()
new_dict = body.model_dump()
secret_keys = {"anthropic_api_key", "openai_api_key", "google_api_key", "openrouter_api_key",
"claude_subscription_token", "openai_subscription_token", "gemini_subscription_token",
"copilot_github_token", "copilot_token", "installation_id"}
safe_changed = [
k for k in new_dict
if k in old_dict and new_dict[k] != old_dict[k] and k not in secret_keys
]
if safe_changed:
_analytics("settings.changed", {"changed_keys": safe_changed})
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(body.model_dump(), f, indent=2)
+2
View File
@@ -163,6 +163,8 @@ async def create_skill(body: SkillCreate):
file_path=fpath,
command=body.command or slug,
)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "skill.created"})
return {"ok": True, "skill": skill.model_dump()}
+4
View File
@@ -62,6 +62,8 @@ async def create_template(body: PromptTemplateCreate):
tags=body.tags,
)
_save(template)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "template.created"})
return {"ok": True, "template": template.model_dump()}
@templates.router.put("/{template_id}")
@@ -88,4 +90,6 @@ async def render_template(body: dict):
placeholder = "{{" + field.name + "}}"
value = values.get(field.name, field.default or "")
rendered = rendered.replace(placeholder, str(value))
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "template.used"})
return {"rendered": rendered}
+15 -2
View File
@@ -21,8 +21,8 @@ logger = logging.getLogger(__name__)
# Default Google OAuth credentials for the OpenSwarm project.
# These are public credentials for a desktop/web OAuth client (safe to embed per Google's docs).
# Users can override via GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET env vars.
_DEFAULT_GOOGLE_CLIENT_ID = "514323102245-ivhcnod6q9jj16ck1hbaiaelivtgtnd1.apps.googleusercontent.com"
_DEFAULT_GOOGLE_CLIENT_SECRET = "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"
_DEFAULT_GOOGLE_CLIENT_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com"
_DEFAULT_GOOGLE_CLIENT_SECRET = "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"
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_ID", _DEFAULT_GOOGLE_CLIENT_ID)
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET)
@@ -410,6 +410,19 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
env = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path())
env.setdefault("PYTHONPATH", "")
# Point uv/uvx at our bundled Python — avoids macOS CLT popup on fresh Macs
# and avoids downloading Python at runtime
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
if _is_packaged:
_resources = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_bundled_python = os.path.join(_resources, "python-env", "bin", "python3")
if os.path.exists(_bundled_python):
env.setdefault("UV_PYTHON", _bundled_python)
else:
_backend = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_venv_python = os.path.join(_backend, ".venv", "bin", "python3")
if os.path.exists(_venv_python):
env.setdefault("UV_PYTHON", _venv_python)
return config
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openswarm",
"version": "1.0.14",
"version": "1.0.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openswarm",
"version": "1.0.14",
"version": "1.0.15",
"hasInstallScript": true,
"dependencies": {
"electron-updater": "^6.3.0",
@@ -29,13 +29,14 @@ const OnboardingModal: React.FC = () => {
.then((r) => r.json())
.then((data) => {
if (data.running) {
setNineRouterReady(true);
// Check if already has subscription
const connections = data.providers?.connections || [];
if (connections.some((p: any) => p.isActive)) {
// Already connected — don't show onboarding
return;
}
// Delay before marking ready — 9Router's OAuth needs time to warm up
setTimeout(() => setNineRouterReady(true), 3000);
} else {
attempts++;
if (attempts < maxAttempts) {
@@ -79,9 +80,16 @@ const OnboardingModal: React.FC = () => {
setOpen(false);
};
// Exact same connect logic as Settings/SubscriptionCards
// Same connect logic as Settings/SubscriptionCards
const handleConnect = async (providerId: string) => {
// Cancel any previous attempt
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
setConnecting(providerId);
// Delay before calling connect — avoids Claude OAuth rate limit on retries
await new Promise(r => setTimeout(r, 1000));
try {
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
method: 'POST',
@@ -118,7 +126,7 @@ const OnboardingModal: React.FC = () => {
} catch {}
}, 5000);
pollTimerRef.current = timer;
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 300000);
setTimeout(() => { clearInterval(timer); pollTimerRef.current = null; setConnecting(null); }, 30000);
} else if (data.flow === 'authorization_code') {
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
@@ -174,7 +182,7 @@ const OnboardingModal: React.FC = () => {
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
setConnecting(null);
}, 300000);
}, 30000);
} else {
setConnecting(null);
@@ -210,6 +210,7 @@ const AgentCard: React.FC<Props> = ({
}) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const cardBoxRef = useRef<HTMLDivElement>(null);
@@ -783,7 +784,7 @@ const AgentCard: React.FC<Props> = ({
<Typography variant="caption" sx={{ color: c.text.tertiary }}>
{formatDuration(session.created_at, (session as any).closed_at, session.status)}
</Typography>
{session.cost_usd > 0 && (
{session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)}
</Typography>
+41 -21
View File
@@ -223,17 +223,23 @@ const SubscriptionCards: React.FC = () => {
};
const handleConnect = async (providerId: string) => {
// Cancel any previous attempt first
if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); }
setConnecting(providerId);
setUserCode('');
// Small delay if retrying — avoids hitting Claude's rate limit
await new Promise(r => setTimeout(r, 500));
try {
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId }),
});
if (!r.ok) { setConnecting(null); return; }
const data = await r.json();
if (data.flow === 'device_code') {
// Device code flow (GitHub, Qwen, etc.) — show code, poll
const code = data.user_code || '';
setUserCode(code);
if (data.verification_uri) window.open(data.verification_uri, '_blank');
@@ -247,6 +253,7 @@ const SubscriptionCards: React.FC = () => {
const pd = await pr.json();
if (pd.success) {
clearInterval(timer);
setPollTimer(null);
setConnecting(null);
setUserCode('');
fetchStatus();
@@ -254,18 +261,36 @@ const SubscriptionCards: React.FC = () => {
} catch {}
}, 5000);
setPollTimer(timer);
setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000);
setTimeout(() => { clearInterval(timer); setPollTimer(null); setConnecting(null); setUserCode(''); }, 300000);
} else if (data.flow === 'authorization_code') {
// Open auth URL as popup — window.opener lets callback page postMessage back
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700');
// Status polling as primary detection
const statusPoller = setInterval(async () => {
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
clearInterval(statusPoller);
setPollTimer(null);
window.removeEventListener('message', msgHandler);
setConnecting(null);
fetchStatus();
}
} catch {}
}, 2000);
setPollTimer(statusPoller);
// postMessage listener as secondary (faster when it works)
const msgHandler = async (event: MessageEvent) => {
const d = event.data;
const callbackData = d?.type === 'oauth_callback' ? d.data : d;
if (callbackData?.code) {
window.removeEventListener('message', msgHandler);
clearInterval(statusPoller);
setPollTimer(null);
if (popup && !popup.closed) popup.close();
try {
await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
@@ -283,21 +308,13 @@ const SubscriptionCards: React.FC = () => {
};
window.addEventListener('message', msgHandler);
const statusPoller = setInterval(async () => {
try {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`);
const sd = await sr.json();
const connections = sd.providers?.connections || [];
if (connections.some((p: any) => p.provider === providerId && p.isActive)) {
clearInterval(statusPoller);
window.removeEventListener('message', msgHandler);
setConnecting(null);
fetchStatus();
}
} catch {}
}, 2000);
setPollTimer(statusPoller);
setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
// Timeout: reset after 30s so user can try again (not 5min)
setTimeout(() => {
clearInterval(statusPoller);
setPollTimer(null);
window.removeEventListener('message', msgHandler);
setConnecting(null);
}, 30000);
} else {
setConnecting(null);
@@ -435,7 +452,8 @@ const UsageStats: React.FC = () => {
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
return `${(n / 1_000_000).toFixed(2)}M`;
};
const costSourceLabel = stats.cost_source === '9router' ? 'equivalent API cost' : stats.cost_source === 'sdk' ? 'via API' : '';
const isSubscription = stats.cost_source === '9router';
const costSourceLabel = isSubscription ? 'saved with your subscription' : stats.cost_source === 'sdk' ? 'via API' : '';
return (
<Box sx={{ mb: 2.5 }}>
@@ -449,10 +467,12 @@ const UsageStats: React.FC = () => {
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>{stats.cost_source === '9router' ? 'Est. API Cost' : 'Total Cost'}</Typography>
<Typography sx={labelSx}>{isSubscription ? 'You Saved' : 'Total Cost'}</Typography>
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
<Typography sx={subSx}>
{costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
{isSubscription
? `${formatCost(stats.avg_cost_per_session)} avg · saved with your subscription`
: costSourceLabel ? `${formatCost(stats.avg_cost_per_session)} avg · ${costSourceLabel}` : 'no cost data'}
</Typography>
</Box>
<Box sx={cardSx}>