[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.pending_approvals.append(approval_req)
session.status = "waiting_approval" 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", { await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id, "session_id": session_id,
"status": "waiting_approval", "status": "waiting_approval",
@@ -548,6 +554,15 @@ class AgentManager:
session_id, request_id, tool_name, safe_input 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 = [ session.pending_approvals = [
a for a in session.pending_approvals if a.id != request_id 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", "") 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: if isinstance(raw_response, list) and raw_response:
text_parts = [ text_parts = [
block.get("text", "") block.get("text", "")
@@ -839,8 +866,11 @@ class AgentManager:
from backend.apps.nine_router import is_running as _9r_running from backend.apps.nine_router import is_running as _9r_running
if _9r_running(): if _9r_running():
options_kwargs["env"] = { options_kwargs["env"] = {
"ANTHROPIC_API_KEY": "9router",
"ANTHROPIC_BASE_URL": "http://localhost:20128", "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: elif global_settings.anthropic_api_key:
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key} options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
else: else:
@@ -871,6 +901,7 @@ class AgentManager:
stream_text_msg_id = None stream_text_msg_id = None
stream_tool_msg_ids_ordered = [] stream_tool_msg_ids_ordered = []
stream_block_index_map = {} stream_block_index_map = {}
_turn_number = 0
async for message in query( async for message in query(
prompt=prompt_stream(), prompt=prompt_stream(),
@@ -976,6 +1007,13 @@ class AgentManager:
"message": tool_msg.model_dump(mode="json"), "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_text_msg_id = None
stream_tool_msg_ids_ordered = [] stream_tool_msg_ids_ordered = []
stream_block_index_map = {} stream_block_index_map = {}
@@ -996,6 +1034,13 @@ class AgentManager:
except Exception as e: except Exception as e:
logger.exception(f"Agent {session_id} error: {e}") logger.exception(f"Agent {session_id} error: {e}")
session.status = "error" 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) error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg) session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", { await ws_manager.send_to_session(session_id, "agent:message", {
@@ -1026,6 +1071,12 @@ class AgentManager:
"tools_list": list(set(tool_names)), "tools_list": list(set(tool_names)),
"session_title": session.name, "session_title": session.name,
"first_user_message": user_messages[0] if user_messages else "", "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) }, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", { await ws_manager.send_to_session(session_id, "agent:status", {
@@ -1193,9 +1244,22 @@ class AgentManager:
session_changed = False session_changed = False
if model and model != session.model: 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.model = model
session_changed = True session_changed = True
if mode and mode != session.mode: 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 session.mode = mode
mode_tools, _, _ = self._resolve_mode(mode) mode_tools, _, _ = self._resolve_mode(mode)
session.allowed_tools = mode_tools session.allowed_tools = mode_tools
@@ -1225,6 +1289,34 @@ class AgentManager:
"message": user_msg.model_dump(mode="json"), "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" session.status = "running"
await ws_manager.send_to_session(session_id, "agent:status", { await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id, "session_id": session_id,
@@ -1317,6 +1409,13 @@ class AgentManager:
session.branches[new_branch_id] = new_branch session.branches[new_branch_id] = new_branch
session.active_branch_id = new_branch_id 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( edited_msg = Message(
role="user", role="user",
content=new_content, content=new_content,
@@ -1592,6 +1691,20 @@ class AgentManager:
session = AgentSession(**data) 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 session.closed_at = None
self.sessions[session_id] = session self.sessions[session_id] = session
+8
View File
@@ -239,6 +239,9 @@ async def subscriptions_poll(body: dict):
code_verifier=body.get("code_verifier"), code_verifier=body.get("code_verifier"),
extra_data=body.get("extra_data"), 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 return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -259,6 +262,9 @@ async def subscriptions_exchange(body: dict):
try: try:
result = await exchange_oauth(provider, code, redirect_uri, code_verifier, state) 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 return result
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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"): if conn and conn.get("id"):
async with httpx.AsyncClient(timeout=10.0) as client: async with httpx.AsyncClient(timeout=10.0) as client:
await client.delete(f"{NINE_ROUTER_API}/providers/{conn['id']}") 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": True}
return {"ok": False, "error": "Connection not found"} return {"ok": False, "error": "Connection not found"}
except Exception as e: 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) } Each task dict has: { browser_id (optional), task, url (optional) }
Returns a list of result dicts, one per task. 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 []) pre_selected = set(pre_selected_browser_ids or [])
async def _run_one(task_def: dict) -> dict: 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.""" """Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
import asyncio
import json import json
import logging import logging
import os import os
import platform import platform
from collections import Counter from collections import Counter
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from backend.config.Apps import SubApp from backend.config.Apps import SubApp
from backend.config.paths import SESSIONS_DIR 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__) 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 @asynccontextmanager
async def analytics_lifespan(): async def analytics_lifespan():
global _heartbeat_task
init_collector() init_collector()
logger.info("PostHog analytics initialised") logger.info("PostHog analytics initialised")
try: try:
from backend.apps.settings.settings import load_settings from backend.apps.settings.settings import load_settings, _save_settings
settings = load_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 = [] providers = []
if getattr(settings, "anthropic_api_key", None): if getattr(settings, "anthropic_api_key", None):
providers.append("anthropic") providers.append("anthropic")
@@ -40,11 +75,15 @@ async def analytics_lifespan():
"platform": platform.platform(), "platform": platform.platform(),
"provider_count": len(providers), "provider_count": len(providers),
"providers": providers, "providers": providers,
"is_first_open": is_first_open,
"days_since_install": days_since_install,
"app_version": APP_VERSION,
}) })
identify({ identify({
"providers_configured": providers, "providers_configured": providers,
"provider_count": len(providers), "provider_count": len(providers),
"app_version": APP_VERSION,
}) })
except Exception as e: except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}") logger.debug(f"Analytics startup event failed (non-critical): {e}")
@@ -56,8 +95,20 @@ async def analytics_lifespan():
except Exception as e: except Exception as e:
logger.debug(f"9Router auto-start skipped: {e}") logger.debug(f"9Router auto-start skipped: {e}")
# Start heartbeat
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
yield yield
# Stop heartbeat
if _heartbeat_task:
_heartbeat_task.cancel()
try:
await _heartbeat_task
except asyncio.CancelledError:
pass
_heartbeat_task = None
# Stop 9Router # Stop 9Router
try: try:
from backend.apps.nine_router import stop as stop_9router from backend.apps.nine_router import stop as stop_9router
@@ -123,7 +174,6 @@ async def usage_summary():
closed = s.get("closed_at") closed = s.get("closed_at")
if created and closed: if created and closed:
try: try:
from datetime import datetime
c_str = created[:19] c_str = created[:19]
cl_str = closed[:19] cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds() 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") @analytics.router.get("/status")
async def analytics_status(): async def analytics_status():
return {"status": "posthog", "enabled": True} 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, updated_at=now,
) )
_save(output) _save(output)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "view.created"})
return {"ok": True, "output": output.model_dump()} 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") @outputs.router.post("/vibe-code")
async def vibe_code(body: VibeCodeRequest): async def vibe_code(body: VibeCodeRequest):
"""Use an LLM to generate or iterate on Output code from a natural language prompt.""" """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: try:
import anthropic import anthropic
except ImportError: except ImportError:
+1
View File
@@ -52,6 +52,7 @@ class AppSettings(BaseModel):
# Analytics: opted in by default, user can toggle off # Analytics: opted in by default, user can toggle off
analytics_opt_in: bool = True analytics_opt_in: bool = True
installation_id: Optional[str] = None installation_id: Optional[str] = None
first_opened_at: Optional[str] = None # ISO timestamp of first app open
class CustomProvider(BaseModel): class CustomProvider(BaseModel):
+33
View File
@@ -52,6 +52,39 @@ async def get_settings():
@settings.router.put("") @settings.router.put("")
async def update_settings(body: AppSettings): 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) os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f: with open(SETTINGS_FILE, "w") as f:
json.dump(body.model_dump(), f, indent=2) json.dump(body.model_dump(), f, indent=2)
+2
View File
@@ -163,6 +163,8 @@ async def create_skill(body: SkillCreate):
file_path=fpath, file_path=fpath,
command=body.command or slug, 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()} return {"ok": True, "skill": skill.model_dump()}
+4
View File
@@ -62,6 +62,8 @@ async def create_template(body: PromptTemplateCreate):
tags=body.tags, tags=body.tags,
) )
_save(template) _save(template)
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "template.created"})
return {"ok": True, "template": template.model_dump()} return {"ok": True, "template": template.model_dump()}
@templates.router.put("/{template_id}") @templates.router.put("/{template_id}")
@@ -88,4 +90,6 @@ async def render_template(body: dict):
placeholder = "{{" + field.name + "}}" placeholder = "{{" + field.name + "}}"
value = values.get(field.name, field.default or "") value = values.get(field.name, field.default or "")
rendered = rendered.replace(placeholder, str(value)) rendered = rendered.replace(placeholder, str(value))
from backend.apps.analytics.collector import record as _analytics
_analytics("feature.used", {"feature": "template.used"})
return {"rendered": rendered} return {"rendered": rendered}
+15 -2
View File
@@ -21,8 +21,8 @@ logger = logging.getLogger(__name__)
# Default Google OAuth credentials for the OpenSwarm project. # 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). # 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. # 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_ID = "6741219524-8vpt07arcc5rvkdb4j1b6v9g53469ugq.apps.googleusercontent.com"
_DEFAULT_GOOGLE_CLIENT_SECRET = "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" _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_ID", _DEFAULT_GOOGLE_CLIENT_ID)
os.environ.setdefault("GOOGLE_OAUTH_CLIENT_SECRET", _DEFAULT_GOOGLE_CLIENT_SECRET) 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 = config.setdefault("env", {})
env.setdefault("PATH", _augmented_path()) env.setdefault("PATH", _augmented_path())
env.setdefault("PYTHONPATH", "") 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 return config
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "openswarm", "name": "openswarm",
"version": "1.0.14", "version": "1.0.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "openswarm", "name": "openswarm",
"version": "1.0.14", "version": "1.0.15",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"electron-updater": "^6.3.0", "electron-updater": "^6.3.0",
@@ -29,13 +29,14 @@ const OnboardingModal: React.FC = () => {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
if (data.running) { if (data.running) {
setNineRouterReady(true);
// Check if already has subscription // Check if already has subscription
const connections = data.providers?.connections || []; const connections = data.providers?.connections || [];
if (connections.some((p: any) => p.isActive)) { if (connections.some((p: any) => p.isActive)) {
// Already connected — don't show onboarding // Already connected — don't show onboarding
return; return;
} }
// Delay before marking ready — 9Router's OAuth needs time to warm up
setTimeout(() => setNineRouterReady(true), 3000);
} else { } else {
attempts++; attempts++;
if (attempts < maxAttempts) { if (attempts < maxAttempts) {
@@ -79,9 +80,16 @@ const OnboardingModal: React.FC = () => {
setOpen(false); setOpen(false);
}; };
// Exact same connect logic as Settings/SubscriptionCards // Same connect logic as Settings/SubscriptionCards
const handleConnect = async (providerId: string) => { 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); setConnecting(providerId);
// Delay before calling connect — avoids Claude OAuth rate limit on retries
await new Promise(r => setTimeout(r, 1000));
try { try {
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, { const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
method: 'POST', method: 'POST',
@@ -118,7 +126,7 @@ const OnboardingModal: React.FC = () => {
} catch {} } catch {}
}, 5000); }, 5000);
pollTimerRef.current = timer; 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') { } else if (data.flow === 'authorization_code') {
const popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); 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 (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; } if (msgHandlerRef.current) { window.removeEventListener('message', msgHandlerRef.current); msgHandlerRef.current = null; }
setConnecting(null); setConnecting(null);
}, 300000); }, 30000);
} else { } else {
setConnecting(null); setConnecting(null);
@@ -210,6 +210,7 @@ const AgentCard: React.FC<Props> = ({
}) => { }) => {
const c = useClaudeTokens(); const c = useClaudeTokens();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const hasApiKey = !!useAppSelector((s) => s.settings.data.anthropic_api_key);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected); const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const cardBoxRef = useRef<HTMLDivElement>(null); const cardBoxRef = useRef<HTMLDivElement>(null);
@@ -783,7 +784,7 @@ const AgentCard: React.FC<Props> = ({
<Typography variant="caption" sx={{ color: c.text.tertiary }}> <Typography variant="caption" sx={{ color: c.text.tertiary }}>
{formatDuration(session.created_at, (session as any).closed_at, session.status)} {formatDuration(session.created_at, (session as any).closed_at, session.status)}
</Typography> </Typography>
{session.cost_usd > 0 && ( {session.cost_usd > 0 && hasApiKey && (
<Typography variant="caption" sx={{ color: c.accent.primary }}> <Typography variant="caption" sx={{ color: c.accent.primary }}>
${session.cost_usd.toFixed(4)} ${session.cost_usd.toFixed(4)}
</Typography> </Typography>
+41 -21
View File
@@ -223,17 +223,23 @@ const SubscriptionCards: React.FC = () => {
}; };
const handleConnect = async (providerId: string) => { const handleConnect = async (providerId: string) => {
// Cancel any previous attempt first
if (pollTimer) { clearInterval(pollTimer); setPollTimer(null); }
setConnecting(providerId); setConnecting(providerId);
setUserCode(''); setUserCode('');
// Small delay if retrying — avoids hitting Claude's rate limit
await new Promise(r => setTimeout(r, 500));
try { try {
const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, { const r = await fetch(`${API_BASE}/agents/subscriptions/connect`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: providerId }), body: JSON.stringify({ provider: providerId }),
}); });
if (!r.ok) { setConnecting(null); return; }
const data = await r.json(); const data = await r.json();
if (data.flow === 'device_code') { if (data.flow === 'device_code') {
// Device code flow (GitHub, Qwen, etc.) — show code, poll
const code = data.user_code || ''; const code = data.user_code || '';
setUserCode(code); setUserCode(code);
if (data.verification_uri) window.open(data.verification_uri, '_blank'); if (data.verification_uri) window.open(data.verification_uri, '_blank');
@@ -247,6 +253,7 @@ const SubscriptionCards: React.FC = () => {
const pd = await pr.json(); const pd = await pr.json();
if (pd.success) { if (pd.success) {
clearInterval(timer); clearInterval(timer);
setPollTimer(null);
setConnecting(null); setConnecting(null);
setUserCode(''); setUserCode('');
fetchStatus(); fetchStatus();
@@ -254,18 +261,36 @@ const SubscriptionCards: React.FC = () => {
} catch {} } catch {}
}, 5000); }, 5000);
setPollTimer(timer); setPollTimer(timer);
setTimeout(() => { clearInterval(timer); setConnecting(null); setUserCode(''); }, 300000); setTimeout(() => { clearInterval(timer); setPollTimer(null); setConnecting(null); setUserCode(''); }, 300000);
} else if (data.flow === 'authorization_code') { } 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'); 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 msgHandler = async (event: MessageEvent) => {
const d = event.data; const d = event.data;
const callbackData = d?.type === 'oauth_callback' ? d.data : d; const callbackData = d?.type === 'oauth_callback' ? d.data : d;
if (callbackData?.code) { if (callbackData?.code) {
window.removeEventListener('message', msgHandler); window.removeEventListener('message', msgHandler);
clearInterval(statusPoller); clearInterval(statusPoller);
setPollTimer(null);
if (popup && !popup.closed) popup.close(); if (popup && !popup.closed) popup.close();
try { try {
await fetch(`${API_BASE}/agents/subscriptions/exchange`, { await fetch(`${API_BASE}/agents/subscriptions/exchange`, {
@@ -283,21 +308,13 @@ const SubscriptionCards: React.FC = () => {
}; };
window.addEventListener('message', msgHandler); window.addEventListener('message', msgHandler);
const statusPoller = setInterval(async () => { // Timeout: reset after 30s so user can try again (not 5min)
try { setTimeout(() => {
const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); clearInterval(statusPoller);
const sd = await sr.json(); setPollTimer(null);
const connections = sd.providers?.connections || []; window.removeEventListener('message', msgHandler);
if (connections.some((p: any) => p.provider === providerId && p.isActive)) { setConnecting(null);
clearInterval(statusPoller); }, 30000);
window.removeEventListener('message', msgHandler);
setConnecting(null);
fetchStatus();
}
} catch {}
}, 2000);
setPollTimer(statusPoller);
setTimeout(() => { clearInterval(statusPoller); window.removeEventListener('message', msgHandler); setConnecting(null); }, 300000);
} else { } else {
setConnecting(null); setConnecting(null);
@@ -435,7 +452,8 @@ const UsageStats: React.FC = () => {
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`;
return `${(n / 1_000_000).toFixed(2)}M`; 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 ( return (
<Box sx={{ mb: 2.5 }}> <Box sx={{ mb: 2.5 }}>
@@ -449,10 +467,12 @@ const UsageStats: React.FC = () => {
</Typography> </Typography>
</Box> </Box>
<Box sx={cardSx}> <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={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
<Typography sx={subSx}> <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> </Typography>
</Box> </Box>
<Box sx={cardSx}> <Box sx={cardSx}>