v3.3.0 GUI dashboard + reports + model expansion + root fix

Engine:
- Fix: inject IS_SANDBOX=1 so Claude Code's --dangerously-skip-permissions
  works under root (real backend runs were exiting rc=1 immediately)
- models: expand to 40 models / 13 providers, tagged CLI vs API
  (NVIDIA NIM, DeepSeek, Mistral, Qwen/DashScope, Groq, Together, OpenRouter,
  Ollama, Gemini) — Qwen/DeepSeek/Llama usable via API
- backends: on_start callback surfaces the exact argv ("what runs behind it")
- orchestrator: require a Playwright screenshot per confirmed finding; collect
  results/activity.json; auto-generate reports after a run
- report.py: HTML always + PDF via Typst engine (.typ source emitted too)

Web dashboard (webgui/, stdlib only — no npm/build):
- Sidebar dashboard (PentAGI-style): Run / Agents / Insights / Reports / Settings
- Multi-target runs; live execution console + per-task activity; finding cards
  with screenshots; backend+provider+model pickers (CLI & API)
- Agents tab: browse 213 + add new .md agents from the UI
- Insights: interactive RL-weight + severity charts
- Reports: download/preview PDF + HTML
- Settings/API: execution mode, per-provider API keys, orchestrator, verbosity
- Endpoints: /api/agents (GET/POST), /api/rl, /api/config, /api/reports,
  /reports/* + /shots/* static serving

Cleanup: retire replaced web stack (frontend React, FastAPI backend, core
orchestration, old test) to legacy/. Active engine + GUI are fully standalone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUP
2026-06-14 23:26:11 -03:00
parent 22a7302a35
commit a5badefc29
205 changed files with 809 additions and 199 deletions
@@ -0,0 +1,109 @@
"""
NeuroSploit v3 - Smart Router Package
Gated by ENABLE_SMART_ROUTER=true env var.
Provides SmartRouter singleton for LLM request routing.
"""
import logging
import os
from typing import Optional
logger = logging.getLogger(__name__)
HAS_SMART_ROUTER = False
_router_instance = None
_registry_instance = None
_refresher_instance = None
if os.getenv("ENABLE_SMART_ROUTER", "false").lower() == "true":
try:
from .provider_registry import ProviderRegistry, Provider, Account
from .token_extractor import TokenExtractor
from .router import SmartRouter
from .token_refresher import TokenRefresher
HAS_SMART_ROUTER = True
logger.info("SmartRouter: Module loaded successfully")
except ImportError as e:
logger.warning(f"SmartRouter: Failed to load: {e}")
async def init_router():
"""Initialize the SmartRouter singleton. Called from main.py startup."""
global _router_instance, _registry_instance, _refresher_instance
if not HAS_SMART_ROUTER:
return
try:
_registry_instance = ProviderRegistry()
# Auto-detect CLI tokens that were never detected before
extractor = TokenExtractor()
auto_detected = 0
for token in extractor.detect_all():
# Only add if provider has no accounts yet
provider = _registry_instance.get_provider(token.provider_id)
if provider and not provider.accounts:
_registry_instance.add_account(
provider_id=token.provider_id,
label=token.label,
credential=token.token,
credential_type=token.credential_type,
source="cli_detect",
refresh_token=token.refresh_token,
expires_at=token.expires_at,
)
auto_detected += 1
if auto_detected:
logger.info(f"SmartRouter: Auto-detected {auto_detected} new CLI provider(s)")
print(f"[SmartRouter] Auto-detected {auto_detected} new CLI provider(s)")
_router_instance = SmartRouter(_registry_instance)
# Discover Gemini CLI project ID (needed for Cloud Code Assist API)
try:
project = await _router_instance.discover_gemini_project()
if project:
print(f"[SmartRouter] Gemini CLI project: {project}")
except Exception as e:
logger.debug(f"SmartRouter: Gemini project discovery skipped: {e}")
_refresher_instance = TokenRefresher(_registry_instance)
await _refresher_instance.start()
# Log connected providers with details
status = _registry_instance.get_all_status()
connected = [s for s in status if s["connected"]]
connected_names = [f"{s['name']}({s['default_model']})" for s in connected]
logger.info(f"SmartRouter: Initialized ({len(connected)}/{len(status)} connected)")
print(f"[SmartRouter] {len(connected)}/{len(status)} providers connected: {', '.join(connected_names) or 'none'}")
except Exception as e:
logger.error(f"SmartRouter: Init failed: {e}")
print(f"[SmartRouter] Init failed: {e}")
_router_instance = None
async def shutdown_router():
"""Shutdown the SmartRouter. Called from main.py shutdown."""
global _refresher_instance
if _refresher_instance:
await _refresher_instance.stop()
_refresher_instance = None
def get_router() -> Optional["SmartRouter"]:
"""Get the SmartRouter singleton (or None if disabled)."""
return _router_instance
def get_registry() -> Optional["ProviderRegistry"]:
"""Get the ProviderRegistry singleton (or None if disabled)."""
return _registry_instance
def get_extractor() -> Optional["TokenExtractor"]:
"""Get a new TokenExtractor instance (or None if disabled)."""
if not HAS_SMART_ROUTER:
return None
return TokenExtractor()
@@ -0,0 +1,545 @@
"""
NeuroSploit v3 - Provider Registry
Central registry of 20 LLM providers + their accounts.
Persists metadata to data/providers.json (credentials stay in-memory only).
"""
import json
import logging
import os
import time
import uuid
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
PROVIDERS_FILE = Path(__file__).parent.parent.parent.parent / "data" / "providers.json"
@dataclass
class Account:
id: str
label: str
source: str # "manual" | "cli_detect" | "env_var"
credential_type: str # "api_key" | "oauth"
created_at: str = ""
last_used: Optional[str] = None
tokens_used: int = 0
is_active: bool = True
expires_at: Optional[float] = None # Unix timestamp for OAuth tokens
model_override: Optional[str] = None
def __post_init__(self):
if not self.created_at:
self.created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict) -> "Account":
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
@dataclass
class Provider:
id: str
name: str
auth_type: str # "api_key" | "oauth"
api_format: str # "anthropic" | "openai_compat" | "gemini" | "ollama"
base_url: str
tier: int # 1=subscription/paid, 2=cheap, 3=free
default_model: str
accounts: Dict[str, Account] = field(default_factory=dict)
env_key: Optional[str] = None # e.g. "ANTHROPIC_API_KEY"
enabled: bool = True # UI toggle: disabled providers are skipped by router
def to_dict(self) -> Dict[str, Any]:
d = asdict(self)
d["accounts"] = {k: v.to_dict() if isinstance(v, Account) else v for k, v in self.accounts.items()}
return d
@classmethod
def from_dict(cls, data: Dict) -> "Provider":
accounts_raw = data.pop("accounts", {})
accounts = {}
for k, v in accounts_raw.items():
if isinstance(v, dict):
accounts[k] = Account.from_dict(v)
else:
accounts[k] = v
filtered = {k: v for k, v in data.items() if k in cls.__dataclass_fields__}
return cls(accounts=accounts, **filtered)
# Default provider definitions
DEFAULT_PROVIDERS: List[Dict] = [
# === NVIDIA NIM Provider (Tier 2 - Free) ===
{
"id": "nim", "name": "NVIDIA NIM", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://integrate.api.nvidia.com/v1",
"tier": 2, "default_model": os.getenv("NIM_MODEL", "openai/gpt-oss-120b"),
"env_key": "NIM_API_KEY",
},
# === OAuth Providers (Tier 1 - Subscription) ===
{
"id": "claude_code", "name": "Claude Code", "auth_type": "oauth",
"api_format": "anthropic", "base_url": "https://api.anthropic.com",
"tier": 1, "default_model": "claude-sonnet-4-20250514",
},
{
"id": "codex_cli", "name": "OpenAI Codex CLI", "auth_type": "oauth",
"api_format": "openai_compat", "base_url": "https://api.openai.com/v1",
"tier": 1, "default_model": "gpt-4o",
},
{
"id": "gemini_cli", "name": "Gemini CLI", "auth_type": "oauth",
"api_format": "gemini_code_assist", "base_url": "https://cloudcode-pa.googleapis.com",
"tier": 1, "default_model": "gemini-2.5-flash",
},
{
"id": "cursor", "name": "Cursor", "auth_type": "oauth",
"api_format": "openai_compat", "base_url": "https://api2.cursor.sh/v1",
"tier": 1, "default_model": "cursor-fast",
},
{
"id": "copilot", "name": "GitHub Copilot", "auth_type": "oauth",
"api_format": "openai_compat", "base_url": "https://api.githubcopilot.com",
"tier": 1, "default_model": "gpt-4o",
},
{
"id": "iflow", "name": "iFlow AI", "auth_type": "oauth",
"api_format": "openai_compat", "base_url": "https://api.iflow.ai/v1",
"tier": 1, "default_model": "kimi-k2",
},
{
"id": "qwen_code", "name": "Qwen Code", "auth_type": "oauth",
"api_format": "openai_compat", "base_url": "https://chat.qwen.ai/api/v1",
"tier": 1, "default_model": "qwen3-coder",
},
{
"id": "kiro", "name": "Kiro AI", "auth_type": "oauth",
"api_format": "anthropic", "base_url": "https://api.anthropic.com",
"tier": 1, "default_model": "claude-sonnet-4-20250514",
},
# === API Key Providers (Tier 1 - Paid) ===
{
"id": "anthropic", "name": "Anthropic", "auth_type": "api_key",
"api_format": "anthropic", "base_url": "https://api.anthropic.com",
"tier": 1, "default_model": "claude-sonnet-4-20250514",
"env_key": "ANTHROPIC_API_KEY",
},
{
"id": "openai", "name": "OpenAI", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://api.openai.com/v1",
"tier": 1, "default_model": "gpt-4o",
"env_key": "OPENAI_API_KEY",
},
{
"id": "gemini", "name": "Gemini", "auth_type": "api_key",
"api_format": "gemini", "base_url": "https://generativelanguage.googleapis.com/v1beta",
"tier": 1, "default_model": "gemini-2.5-flash",
"env_key": "GEMINI_API_KEY",
},
{
"id": "openrouter", "name": "OpenRouter", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://openrouter.ai/api/v1",
"tier": 1, "default_model": "anthropic/claude-sonnet-4-20250514",
"env_key": "OPENROUTER_API_KEY",
},
# === API Key Providers (Tier 2 - Cheap) ===
{
"id": "glm", "name": "GLM (Zhipu AI)", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://open.bigmodel.cn/api/paas/v4",
"tier": 2, "default_model": "glm-4-flash",
"env_key": "GLM_API_KEY",
},
{
"id": "kimi", "name": "Kimi (Moonshot)", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://api.moonshot.cn/v1",
"tier": 2, "default_model": "moonshot-v1-8k",
"env_key": "KIMI_API_KEY",
},
{
"id": "minimax", "name": "Minimax", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://api.minimax.chat/v1",
"tier": 2, "default_model": "abab6.5-chat",
"env_key": "MINIMAX_API_KEY",
},
{
"id": "together", "name": "Together AI", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://api.together.xyz/v1",
"tier": 2, "default_model": "meta-llama/Llama-3-70b-chat-hf",
"env_key": "TOGETHER_API_KEY",
},
{
"id": "fireworks", "name": "Fireworks AI", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "https://api.fireworks.ai/inference/v1",
"tier": 2, "default_model": "accounts/fireworks/models/llama-v3p1-70b-instruct",
"env_key": "FIREWORKS_API_KEY",
},
# === Local Providers (Tier 3 - Free/Self-hosted) ===
{
"id": "ollama", "name": "Ollama", "auth_type": "api_key",
"api_format": "ollama", "base_url": "http://localhost:11434",
"tier": 3, "default_model": "llama3",
"env_key": "OLLAMA_API_KEY",
},
{
"id": "lmstudio", "name": "LM Studio", "auth_type": "api_key",
"api_format": "openai_compat", "base_url": "http://localhost:1234/v1",
"tier": 3, "default_model": "local-model",
"env_key": "LMSTUDIO_API_KEY",
},
]
class ProviderRegistry:
"""Central registry for LLM providers and their accounts.
Metadata persists to data/providers.json.
Credentials are kept IN-MEMORY ONLY for security (re-extracted from CLI on startup).
"""
def __init__(self):
self._providers: Dict[str, Provider] = {}
self._credentials: Dict[str, str] = {} # acct_id -> credential (IN-MEMORY)
self._refresh_tokens: Dict[str, str] = {} # acct_id -> refresh_token (IN-MEMORY)
self._seed_defaults()
self._load()
self._seed_env_credentials()
self._restore_cli_credentials()
# ── Seeding ──────────────────────────────────────────────
def _seed_defaults(self):
"""Register built-in provider definitions."""
for pdef in DEFAULT_PROVIDERS:
pid = pdef["id"]
if pid not in self._providers:
self._providers[pid] = Provider(
id=pid,
name=pdef["name"],
auth_type=pdef["auth_type"],
api_format=pdef["api_format"],
base_url=pdef["base_url"],
tier=pdef["tier"],
default_model=pdef["default_model"],
env_key=pdef.get("env_key"),
)
def _seed_env_credentials(self):
"""Auto-load credentials from environment variables."""
for pid, provider in self._providers.items():
if not provider.env_key:
continue
value = os.getenv(provider.env_key, "").strip()
if not value:
continue
# Check if we already have an env_var account
existing = [a for a in provider.accounts.values() if a.source == "env_var"]
if existing:
acct = existing[0]
# Update credential in memory
self._credentials[acct.id] = value
# Reactivate if deactivated — env key is set, it should be active
if not acct.is_active:
acct.is_active = True
logger.info(f"SmartRouter: Reactivated {acct.label} (env key present)")
continue
# Create new env_var account
acct_id = f"acct_{uuid.uuid4().hex[:8]}"
acct = Account(
id=acct_id,
label=f"{provider.name} (env)",
source="env_var",
credential_type="api_key",
)
provider.accounts[acct_id] = acct
self._credentials[acct_id] = value
logger.info(f"SmartRouter: Loaded {provider.env_key} for {provider.name}")
self._save()
def _restore_cli_credentials(self):
"""Re-extract CLI tokens on startup for cli_detect accounts.
CLI tokens are never persisted to disk. On restart, we re-detect
them from the CLI tools (same files TokenExtractor reads).
Also reactivates accounts that were deactivated due to expired tokens.
"""
try:
from .token_extractor import TokenExtractor
extractor = TokenExtractor()
except ImportError:
return
restored = 0
for pid, provider in self._providers.items():
cli_accounts = [a for a in provider.accounts.values() if a.source == "cli_detect"]
if not cli_accounts:
continue
# Try to re-extract token for this provider
token = extractor.detect(pid)
if not token:
logger.debug(f"SmartRouter: No CLI token found for {provider.name}")
continue
# Update the first cli_detect account (remove duplicates)
primary = cli_accounts[0]
self._credentials[primary.id] = token.token
if token.refresh_token:
self._refresh_tokens[primary.id] = token.refresh_token
# Update expires_at (token_extractor may have parsed it from the credential file)
if token.expires_at:
primary.expires_at = token.expires_at
elif primary.expires_at is None and token.credential_type == "oauth":
# OAuth tokens without expiry — assume 1 hour from now as a hint
# so the TokenRefresher will check and refresh them
import time as _time
primary.expires_at = _time.time() + 3600
logger.debug(f"SmartRouter: Set default 1h expiry for {primary.label}")
# Reactivate if it was deactivated (token may have been refreshed externally)
if not primary.is_active:
primary.is_active = True
logger.info(f"SmartRouter: Reactivated {primary.label} (fresh token found)")
# Remove duplicate cli_detect accounts (keep only primary)
for dup in cli_accounts[1:]:
del provider.accounts[dup.id]
self._credentials.pop(dup.id, None)
self._refresh_tokens.pop(dup.id, None)
logger.info(f"SmartRouter: Removed duplicate account {dup.id} for {provider.name}")
restored += 1
logger.info(
f"SmartRouter: Restored CLI credential for {provider.name} "
f"(token={'***' + token.token[-8:]}, refresh={'yes' if token.refresh_token else 'no'}, "
f"expires={primary.expires_at})"
)
if restored:
self._save()
logger.info(f"SmartRouter: Restored {restored} CLI provider(s)")
# ── CRUD ─────────────────────────────────────────────────
def add_account(
self,
provider_id: str,
label: str,
credential: str,
credential_type: str = "api_key",
source: str = "manual",
refresh_token: Optional[str] = None,
expires_at: Optional[float] = None,
model_override: Optional[str] = None,
) -> Optional[str]:
"""Add a new account to a provider. Returns account ID or None."""
provider = self._providers.get(provider_id)
if not provider:
logger.warning(f"SmartRouter: Unknown provider {provider_id}")
return None
# Deduplicate: if a cli_detect account already exists, update it instead
if source == "cli_detect":
existing = [a for a in provider.accounts.values() if a.source == "cli_detect"]
if existing:
acct = existing[0]
self._credentials[acct.id] = credential
if refresh_token:
self._refresh_tokens[acct.id] = refresh_token
acct.expires_at = expires_at
acct.is_active = True
self._save()
logger.info(f"SmartRouter: Updated existing CLI account {acct.label} for {provider.name}")
return acct.id
acct_id = f"acct_{uuid.uuid4().hex[:8]}"
acct = Account(
id=acct_id,
label=label,
source=source,
credential_type=credential_type,
expires_at=expires_at,
model_override=model_override,
)
provider.accounts[acct_id] = acct
self._credentials[acct_id] = credential
if refresh_token:
self._refresh_tokens[acct_id] = refresh_token
self._save()
logger.info(f"SmartRouter: Added account {label} to {provider.name}")
return acct_id
def remove_account(self, provider_id: str, account_id: str) -> bool:
"""Remove an account from a provider."""
provider = self._providers.get(provider_id)
if not provider or account_id not in provider.accounts:
return False
del provider.accounts[account_id]
self._credentials.pop(account_id, None)
self._refresh_tokens.pop(account_id, None)
self._save()
logger.info(f"SmartRouter: Removed account {account_id} from {provider.name}")
return True
def get_credential(self, account_id: str) -> Optional[str]:
"""Get credential for an account (from memory only)."""
return self._credentials.get(account_id)
def get_refresh_token(self, account_id: str) -> Optional[str]:
"""Get refresh token for an account (from memory only)."""
return self._refresh_tokens.get(account_id)
def update_credential(
self,
account_id: str,
new_credential: str,
new_expires_at: Optional[float] = None,
):
"""Update credential (and optionally expiry) for an account."""
self._credentials[account_id] = new_credential
# Update expiry on the account object
for provider in self._providers.values():
if account_id in provider.accounts:
provider.accounts[account_id].expires_at = new_expires_at
self._save()
break
def deactivate_account(self, account_id: str):
"""Mark account as inactive (e.g., on 401/403)."""
for provider in self._providers.values():
if account_id in provider.accounts:
provider.accounts[account_id].is_active = False
self._save()
logger.warning(f"SmartRouter: Deactivated account {account_id}")
break
def reactivate_account(self, account_id: str) -> bool:
"""Re-activate a deactivated account (e.g., after token refresh)."""
for provider in self._providers.values():
if account_id in provider.accounts:
acct = provider.accounts[account_id]
if not acct.is_active:
acct.is_active = True
self._save()
logger.info(f"SmartRouter: Reactivated account {account_id}")
return True
return False
# ── Queries ──────────────────────────────────────────────
def get_active_accounts(self, provider_id: str) -> List[Account]:
"""Get all active accounts for a provider."""
provider = self._providers.get(provider_id)
if not provider:
return []
return [
a for a in provider.accounts.values()
if a.is_active and a.id in self._credentials
]
def get_provider(self, provider_id: str) -> Optional[Provider]:
"""Get a provider by ID."""
return self._providers.get(provider_id)
def get_providers_by_tier(self, tier: int) -> List[Provider]:
"""Get all providers in a tier that have active accounts."""
return [
p for p in self._providers.values()
if p.tier == tier and self.get_active_accounts(p.id)
]
def get_all_providers(self) -> List[Provider]:
"""Get all registered providers."""
return list(self._providers.values())
def toggle_provider(self, provider_id: str, enabled: bool) -> bool:
"""Enable or disable a provider. Disabled providers are skipped by the router."""
provider = self._providers.get(provider_id)
if not provider:
return False
provider.enabled = enabled
self._save()
logger.info(f"Provider {provider_id} {'enabled' if enabled else 'disabled'}")
return True
def get_all_status(self) -> List[Dict]:
"""Get status summary for all providers (for API/UI)."""
result = []
for p in self._providers.values():
active = self.get_active_accounts(p.id)
total_tokens = sum(a.tokens_used for a in p.accounts.values())
result.append({
"id": p.id,
"name": p.name,
"auth_type": p.auth_type,
"api_format": p.api_format,
"tier": p.tier,
"default_model": p.default_model,
"accounts_total": len(p.accounts),
"accounts_active": len(active),
"total_tokens_used": total_tokens,
"connected": len(active) > 0,
"enabled": getattr(p, "enabled", True),
})
return result
def record_usage(self, account_id: str, tokens: int):
"""Record token usage for an account."""
for provider in self._providers.values():
if account_id in provider.accounts:
provider.accounts[account_id].tokens_used += tokens
provider.accounts[account_id].last_used = time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime()
)
# Save periodically (every 10th call to avoid I/O spam)
if provider.accounts[account_id].tokens_used % 10000 < tokens:
self._save()
break
# ── Persistence ──────────────────────────────────────────
def _save(self):
"""Save provider metadata to JSON (NO credentials)."""
try:
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
data = {
pid: p.to_dict()
for pid, p in self._providers.items()
}
tmp = PROVIDERS_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(data, f, indent=2, default=str)
tmp.rename(PROVIDERS_FILE)
except Exception as e:
logger.warning(f"SmartRouter: Failed to save providers: {e}")
def _load(self):
"""Load provider metadata from JSON, merge with defaults."""
if not PROVIDERS_FILE.exists():
return
try:
with open(PROVIDERS_FILE) as f:
data = json.load(f)
for pid, pdata in data.items():
if pid in self._providers:
# Merge accounts from disk into existing provider
saved_accounts = pdata.get("accounts", {})
for aid, adata in saved_accounts.items():
if isinstance(adata, dict):
self._providers[pid].accounts[aid] = Account.from_dict(adata)
else:
# Unknown provider from disk - load it
self._providers[pid] = Provider.from_dict(pdata)
logger.info(f"SmartRouter: Loaded {len(data)} providers from disk")
except Exception as e:
logger.warning(f"SmartRouter: Failed to load providers: {e}")
@@ -0,0 +1,606 @@
"""
NeuroSploit v3 - Smart Router
Core routing engine with 4 API format translators, tier-based failover,
round-robin load balancing, and quota tracking.
"""
import asyncio
import logging
import time
from typing import Any, Dict, List, Optional, Tuple
from .provider_registry import Account, Provider, ProviderRegistry
logger = logging.getLogger(__name__)
class QuotaTracker:
"""Tracks per-account quota exhaustion with automatic recovery."""
def __init__(self):
self._exhausted: Dict[str, float] = {} # acct_id -> retry_after_timestamp
self._round_robin: Dict[str, int] = {} # provider_id -> last_index
def record_exhausted(self, account_id: str, retry_after: int = 300):
"""Mark an account as quota-exhausted."""
self._exhausted[account_id] = time.time() + retry_after
logger.warning(f"QuotaTracker: {account_id} exhausted, retry after {retry_after}s")
def is_available(self, account_id: str) -> bool:
"""Check if an account is available (not exhausted or recovered)."""
if account_id not in self._exhausted:
return True
if time.time() > self._exhausted[account_id]:
del self._exhausted[account_id]
return True
return False
def next_account(self, provider_id: str, accounts: List[Account]) -> Optional[Account]:
"""Round-robin selection from available accounts."""
available = [a for a in accounts if self.is_available(a.id)]
if not available:
return None
idx = self._round_robin.get(provider_id, -1) + 1
idx = idx % len(available)
self._round_robin[provider_id] = idx
return available[idx]
class SmartRouter:
"""Smart LLM router with format translation, failover, and load balancing.
Routing priority:
1. Preferred provider (if specified)
2. Tier 1 providers (subscription + paid API keys)
3. Tier 2 providers (cheap API keys)
4. Tier 3 providers (free/local)
"""
def __init__(self, registry: ProviderRegistry):
self.registry = registry
self.quota = QuotaTracker()
self._total_requests = 0
self._total_tokens = 0
self._failover_count = 0
self._last_provider: Optional[str] = None # Last provider used
self._last_model: Optional[str] = None # Last model used
self._last_account_label: Optional[str] = None # Last account label
self._gemini_project_id: Optional[str] = None # Cached Gemini CLI project ID
async def generate(
self,
prompt: str,
system: str = "",
max_tokens: int = 4096,
preferred_provider: Optional[str] = None,
model: Optional[str] = None,
temperature: float = 0.7,
) -> str:
"""Route a generation request through available providers.
Tries providers in tier order, fails over on errors.
Returns generated text or raises if all providers fail.
"""
candidates = self._build_candidate_list(preferred_provider)
if not candidates:
raise RuntimeError(
f"SmartRouter: No providers available "
f"(preferred={preferred_provider}, model={model})"
)
logger.info(
f"SmartRouter: {len(candidates)} candidates "
f"[{', '.join(f'{p.id}/{a.label}' for p, a in candidates)}]"
)
last_error = None
for provider, account in candidates:
try:
use_model = model or account.model_override or provider.default_model
logger.info(f"SmartRouter: Trying {provider.name} ({use_model}) via {account.label}")
text, tokens_used = await self._call_provider(
provider=provider,
account=account,
prompt=prompt,
system=system,
max_tokens=max_tokens,
model=use_model,
temperature=temperature,
)
# Record success + track which provider/model served the request
self._total_requests += 1
self._total_tokens += tokens_used
self._last_provider = provider.id
self._last_model = use_model
self._last_account_label = account.label
self.registry.record_usage(account.id, tokens_used)
logger.info(f"SmartRouter: OK — served by {provider.name} ({use_model})")
return text
except QuotaExhaustedError as e:
retry_after = getattr(e, "retry_after", 300)
self.quota.record_exhausted(account.id, retry_after)
self._failover_count += 1
last_error = e
logger.info(f"SmartRouter: {provider.name}/{account.label} quota exhausted, trying next")
except AuthenticationError as e:
# For CLI/OAuth accounts, try re-extracting token and RETRY immediately
if account.source == "cli_detect":
refreshed = await self._try_reextract_cli_token(provider.id, account.id)
if refreshed:
logger.info(f"SmartRouter: {provider.name}/{account.label} token re-extracted, retrying NOW")
try:
text, tokens_used = await self._call_provider(
provider=provider,
account=account,
prompt=prompt,
system=system,
max_tokens=max_tokens,
model=use_model,
temperature=temperature,
)
self._total_requests += 1
self._total_tokens += tokens_used
self._last_provider = provider.id
self._last_model = use_model
self._last_account_label = account.label
self.registry.record_usage(account.id, tokens_used)
logger.info(f"SmartRouter: OK — served by {provider.name} ({use_model}) after token re-extract")
return text
except Exception as retry_err:
logger.warning(f"SmartRouter: {provider.name} retry after re-extract also failed: {retry_err}")
last_error = retry_err
self.registry.deactivate_account(account.id)
self._failover_count += 1
if last_error is None:
last_error = e
logger.warning(f"SmartRouter: {provider.name}/{account.label} auth failed, deactivated")
except Exception as e:
self._failover_count += 1
last_error = e
logger.warning(f"SmartRouter: {provider.name}/{account.label} error: {e}")
raise RuntimeError(f"SmartRouter: All providers failed. Last error: {last_error}")
def _build_candidate_list(
self, preferred: Optional[str] = None
) -> List[Tuple[Provider, Account]]:
"""Build ordered list of (provider, account) candidates.
If preferred is set, that provider is tried FIRST, then falls back
to other providers of the same tier if all accounts fail.
If preferred is not set, all providers are tried by tier.
"""
candidates = []
seen_account_ids = set()
if preferred:
# Preferred provider goes first in candidate list
provider = self.registry.get_provider(preferred)
if provider:
accounts = self.registry.get_active_accounts(preferred)
for acct in accounts:
if self.quota.is_available(acct.id):
candidates.append((provider, acct))
seen_account_ids.add(acct.id)
if not candidates:
logger.warning(
f"SmartRouter: Preferred provider '{preferred}' has no active accounts! "
f"Falling back to all providers."
)
# Add remaining providers as fallback (by tier)
for tier in (1, 2, 3):
providers = self.registry.get_providers_by_tier(tier)
for provider in providers:
if not getattr(provider, "enabled", True):
continue
acct = self.quota.next_account(
provider.id,
self.registry.get_active_accounts(provider.id),
)
if acct and acct.id not in seen_account_ids:
candidates.append((provider, acct))
seen_account_ids.add(acct.id)
return candidates
async def _call_provider(
self,
provider: Provider,
account: Account,
prompt: str,
system: str,
max_tokens: int,
model: str,
temperature: float,
) -> Tuple[str, int]:
"""Make an API call to a provider. Returns (text, tokens_used)."""
credential = self.registry.get_credential(account.id)
if not credential:
raise AuthenticationError(f"No credential for {account.id}")
format_map = {
"anthropic": (self._build_anthropic_request, self._parse_anthropic_response),
"openai_compat": (self._build_openai_request, self._parse_openai_response),
"gemini": (self._build_gemini_request, self._parse_gemini_response),
"gemini_code_assist": (self._build_gemini_code_assist_request, self._parse_gemini_code_assist_response),
"ollama": (self._build_ollama_request, self._parse_ollama_response),
}
builder, parser = format_map.get(provider.api_format, (None, None))
if not builder:
raise ValueError(f"Unknown API format: {provider.api_format}")
url, headers, payload = builder(
base_url=provider.base_url,
credential=credential,
credential_type=account.credential_type,
prompt=prompt,
system=system,
max_tokens=max_tokens,
model=model,
temperature=temperature,
)
import aiohttp
async with aiohttp.ClientSession() as session:
async with session.post(
url,
headers=headers,
json=payload,
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status == 429:
retry_after = int(resp.headers.get("Retry-After", "300"))
raise QuotaExhaustedError(
f"Rate limited by {provider.name}",
retry_after=retry_after,
)
if resp.status in (401, 403):
raise AuthenticationError(
f"Authentication failed for {provider.name}: {resp.status}"
)
if resp.status >= 400:
body = await resp.text()
raise RuntimeError(
f"{provider.name} returned {resp.status}: {body[:300]}"
)
data = await resp.json()
return parser(data)
# ── Format Builders ──────────────────────────────────────
def _build_anthropic_request(
self, base_url: str, credential: str, credential_type: str,
prompt: str, system: str, max_tokens: int, model: str, temperature: float,
) -> Tuple[str, Dict, Dict]:
url = f"{base_url}/v1/messages"
headers = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
}
if credential_type == "oauth":
headers["Authorization"] = f"Bearer {credential}"
else:
headers["x-api-key"] = credential
messages = [{"role": "user", "content": prompt}]
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
if system:
payload["system"] = system
return url, headers, payload
def _build_openai_request(
self, base_url: str, credential: str, credential_type: str,
prompt: str, system: str, max_tokens: int, model: str, temperature: float,
) -> Tuple[str, Dict, Dict]:
url = f"{base_url}/chat/completions"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {credential}",
}
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
payload = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
return url, headers, payload
def _build_gemini_request(
self, base_url: str, credential: str, credential_type: str,
prompt: str, system: str, max_tokens: int, model: str, temperature: float,
) -> Tuple[str, Dict, Dict]:
if credential_type == "oauth":
url = f"{base_url}/models/{model}:generateContent"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {credential}",
}
else:
url = f"{base_url}/models/{model}:generateContent?key={credential}"
headers = {"Content-Type": "application/json"}
contents = [{"parts": [{"text": prompt}]}]
payload = {
"contents": contents,
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": temperature,
},
}
if system:
payload["systemInstruction"] = {"parts": [{"text": system}]}
return url, headers, payload
def _build_gemini_code_assist_request(
self, base_url: str, credential: str, credential_type: str,
prompt: str, system: str, max_tokens: int, model: str, temperature: float,
) -> Tuple[str, Dict, Dict]:
"""Build request for Google Cloud Code Assist API (Gemini CLI endpoint)."""
url = f"{base_url}/v1internal:generateContent"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {credential}",
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"X-Goog-Api-Client": "gl-node/22.17.0",
}
inner_request: Dict[str, Any] = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {
"maxOutputTokens": max_tokens,
"temperature": temperature,
},
}
if system:
inner_request["systemInstruction"] = {"parts": [{"text": system}]}
payload: Dict[str, Any] = {
"model": model,
"request": inner_request,
}
# Include project ID if discovered
if self._gemini_project_id:
payload["project"] = self._gemini_project_id
return url, headers, payload
def _build_ollama_request(
self, base_url: str, credential: str, credential_type: str,
prompt: str, system: str, max_tokens: int, model: str, temperature: float,
) -> Tuple[str, Dict, Dict]:
url = f"{base_url}/api/generate"
headers = {"Content-Type": "application/json"}
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {
"num_predict": max_tokens,
"temperature": temperature,
},
}
if system:
payload["system"] = system
return url, headers, payload
# ── Response Parsers ─────────────────────────────────────
def _parse_anthropic_response(self, data: Dict) -> Tuple[str, int]:
text = ""
for block in data.get("content", []):
if block.get("type") == "text":
text += block.get("text", "")
usage = data.get("usage", {})
tokens = usage.get("input_tokens", 0) + usage.get("output_tokens", 0)
return text, tokens
def _parse_openai_response(self, data: Dict) -> Tuple[str, int]:
choices = data.get("choices", [])
text = choices[0]["message"]["content"] if choices else ""
usage = data.get("usage", {})
tokens = usage.get("total_tokens", 0)
return text, tokens
def _parse_gemini_response(self, data: Dict) -> Tuple[str, int]:
candidates = data.get("candidates", [])
text = ""
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
text = "".join(p.get("text", "") for p in parts)
usage = data.get("usageMetadata", {})
tokens = usage.get("totalTokenCount", 0)
return text, tokens
def _parse_gemini_code_assist_response(self, data: Dict) -> Tuple[str, int]:
"""Parse Cloud Code Assist API response (wraps standard Gemini in 'response' field)."""
response = data.get("response", data)
candidates = response.get("candidates", [])
text = ""
if candidates:
parts = candidates[0].get("content", {}).get("parts", [])
text = "".join(p.get("text", "") for p in parts)
usage = response.get("usageMetadata", {})
tokens = usage.get("totalTokenCount", 0)
return text, tokens
def _parse_ollama_response(self, data: Dict) -> Tuple[str, int]:
text = data.get("response", "")
# Ollama doesn't always return token counts
tokens = data.get("eval_count", 0) + data.get("prompt_eval_count", 0)
return text, tokens
# ── Gemini CLI Project Discovery ────────────────────────────
async def discover_gemini_project(self) -> Optional[str]:
"""Discover Gemini CLI project ID via loadCodeAssist API call."""
if self._gemini_project_id:
return self._gemini_project_id
provider = self.registry.get_provider("gemini_cli")
if not provider or not provider.accounts:
return None
account = next(iter(provider.accounts.values()), None)
if not account or not account.is_active:
return None
credential = self.registry.get_credential(account.id)
if not credential:
return None
try:
import aiohttp
url = f"{provider.base_url}/v1internal:loadCodeAssist"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {credential}",
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
}
payload = {
"metadata": {
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
}
}
async with aiohttp.ClientSession() as session:
async with session.post(
url, headers=headers, json=payload,
timeout=aiohttp.ClientTimeout(total=15),
) as resp:
if resp.status == 200:
data = await resp.json()
project_data = data.get("cloudaicompanionProject", {})
project_id = (
project_data.get("id", "")
if isinstance(project_data, dict)
else str(project_data)
)
if project_id:
self._gemini_project_id = project_id
logger.info(f"SmartRouter: Gemini CLI project: {project_id}")
return project_id
except Exception as e:
logger.debug(f"SmartRouter: Gemini project discovery failed: {e}")
return None
# ── CLI Token Recovery ─────────────────────────────────────
async def _try_reextract_cli_token(self, provider_id: str, account_id: str) -> bool:
"""Try to re-extract/refresh a CLI token when auth fails.
Strategy:
1. Re-extract from CLI credential files (may have been refreshed externally)
2. If refresh_token is available, try OAuth refresh
"""
# Strategy 1: Re-extract from disk
try:
from .token_extractor import TokenExtractor
extractor = TokenExtractor()
token = extractor.detect(provider_id)
if token and token.token:
self.registry.update_credential(account_id, token.token, token.expires_at)
if token.refresh_token:
self.registry._refresh_tokens[account_id] = token.refresh_token
self.registry.reactivate_account(account_id)
logger.info(f"SmartRouter: Re-extracted CLI token for {provider_id}")
return True
except Exception as e:
logger.debug(f"SmartRouter: CLI re-extraction failed for {provider_id}: {e}")
# Strategy 2: OAuth refresh if we have a refresh_token
refresh_token = self.registry.get_refresh_token(account_id)
if refresh_token:
try:
from .token_refresher import TokenRefresher
refresher = TokenRefresher(self.registry)
success = await refresher._refresh_token(provider_id, account_id, refresh_token)
if success:
self.registry.reactivate_account(account_id)
logger.info(f"SmartRouter: OAuth-refreshed token for {provider_id}")
return True
except Exception as e:
logger.debug(f"SmartRouter: OAuth refresh failed for {provider_id}: {e}")
return False
# ── Testing & Status ─────────────────────────────────────
async def test_account(
self, provider_id: str, account_id: str
) -> Tuple[bool, str]:
"""Test connectivity for a specific account. Returns (success, message)."""
provider = self.registry.get_provider(provider_id)
if not provider:
return False, f"Unknown provider: {provider_id}"
account = provider.accounts.get(account_id)
if not account:
return False, f"Unknown account: {account_id}"
try:
text, tokens = await self._call_provider(
provider=provider,
account=account,
prompt="Say 'OK' and nothing else.",
system="",
max_tokens=10,
model=account.model_override or provider.default_model,
temperature=0,
)
return True, f"Connected. Response: {text[:50]}"
except Exception as e:
return False, str(e)
def get_status(self) -> Dict:
"""Get router statistics."""
return {
"total_requests": self._total_requests,
"total_tokens": self._total_tokens,
"failover_count": self._failover_count,
"last_provider": self._last_provider,
"last_model": self._last_model,
"last_account": self._last_account_label,
"providers": self.registry.get_all_status(),
}
# ── Custom Exceptions ────────────────────────────────────────
class QuotaExhaustedError(Exception):
def __init__(self, message: str, retry_after: int = 300):
super().__init__(message)
self.retry_after = retry_after
class AuthenticationError(Exception):
pass
@@ -0,0 +1,290 @@
"""
NeuroSploit v3 - CLI Token Extractor
READ-ONLY extraction of OAuth/API tokens from 8 CLI tools.
Never modifies any CLI tool's files or state.
"""
import json
import logging
import os
import platform
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
@dataclass
class ExtractedToken:
provider_id: str
credential_type: str # "oauth" | "api_key"
token: str
refresh_token: Optional[str] = None
expires_at: Optional[float] = None
label: str = ""
class TokenExtractor:
"""Extracts tokens from CLI tools installed on the system."""
EXTRACTORS = [
"claude_code", "codex_cli", "cursor",
"copilot", "iflow", "qwen_code", "kiro",
]
def detect(self, provider_id: str) -> Optional[ExtractedToken]:
"""Detect a token for a specific provider."""
method = getattr(self, f"_extract_{provider_id}", None)
if not method:
return None
try:
return method()
except Exception as e:
logger.debug(f"TokenExtractor: Failed to extract {provider_id}: {e}")
return None
def detect_all(self) -> List[ExtractedToken]:
"""Scan all known CLI tools and return found tokens."""
tokens = []
for pid in self.EXTRACTORS:
result = self.detect(pid)
if result:
tokens.append(result)
logger.info(f"TokenExtractor: Found {pid} token")
return tokens
# ── Individual Extractors ────────────────────────────────
def _extract_claude_code(self) -> Optional[ExtractedToken]:
"""Claude Code: macOS Keychain or ~/.claude/.credentials.json"""
is_mac = platform.system() == "Darwin"
if is_mac:
token = self._read_macos_keychain("Claude Code-credentials")
if token:
# Parse the JSON credential blob
try:
cred = json.loads(token)
access_token = cred.get("claudeAiOauth", {}).get("accessToken")
refresh = cred.get("claudeAiOauth", {}).get("refreshToken")
expires = cred.get("claudeAiOauth", {}).get("expiresAt")
if access_token:
return ExtractedToken(
provider_id="claude_code",
credential_type="oauth",
token=access_token,
refresh_token=refresh,
expires_at=expires / 1000.0 if expires else None,
label="Claude Code (Keychain)",
)
except (json.JSONDecodeError, AttributeError):
pass
# Linux fallback: ~/.claude/.credentials.json
creds_path = Path.home() / ".claude" / ".credentials.json"
if creds_path.exists():
try:
data = json.loads(creds_path.read_text())
access_token = data.get("claudeAiOauth", {}).get("accessToken")
refresh = data.get("claudeAiOauth", {}).get("refreshToken")
expires = data.get("claudeAiOauth", {}).get("expiresAt")
if access_token:
return ExtractedToken(
provider_id="claude_code",
credential_type="oauth",
token=access_token,
refresh_token=refresh,
expires_at=expires / 1000.0 if expires else None,
label="Claude Code (credentials file)",
)
except Exception:
pass
return None
def _extract_codex_cli(self) -> Optional[ExtractedToken]:
"""OpenAI Codex CLI: ~/.codex/auth.json"""
auth_path = Path.home() / ".codex" / "auth.json"
if not auth_path.exists():
return None
try:
data = json.loads(auth_path.read_text())
# Codex uses OAuth2 PKCE
access_token = data.get("access_token") or data.get("token")
refresh = data.get("refresh_token")
expires = data.get("expires_at")
if access_token:
return ExtractedToken(
provider_id="codex_cli",
credential_type="oauth",
token=access_token,
refresh_token=refresh,
expires_at=expires,
label="Codex CLI",
)
except Exception:
pass
return None
def _extract_cursor(self) -> Optional[ExtractedToken]:
"""Cursor: SQLite state.vscdb database"""
is_mac = platform.system() == "Darwin"
if is_mac:
base = Path.home() / "Library" / "Application Support" / "Cursor" / "User" / "globalStorage"
else:
base = Path.home() / ".config" / "Cursor" / "User" / "globalStorage"
db_path = base / "state.vscdb"
if not db_path.exists():
return None
try:
import sqlite3
conn = sqlite3.connect(str(db_path), timeout=1)
cursor = conn.cursor()
cursor.execute(
"SELECT value FROM ItemTable WHERE key = ?",
("cursorAuth/accessToken",)
)
row = cursor.fetchone()
conn.close()
if row and row[0]:
return ExtractedToken(
provider_id="cursor",
credential_type="oauth",
token=row[0],
label="Cursor IDE",
)
except Exception:
pass
return None
def _extract_copilot(self) -> Optional[ExtractedToken]:
"""GitHub Copilot: ~/.config/gh/hosts.yml"""
hosts_path = Path.home() / ".config" / "gh" / "hosts.yml"
if not hosts_path.exists():
return None
try:
content = hosts_path.read_text()
# Simple YAML parsing for oauth_token
for line in content.split("\n"):
line = line.strip()
if line.startswith("oauth_token:"):
token = line.split(":", 1)[1].strip()
if token:
return ExtractedToken(
provider_id="copilot",
credential_type="oauth",
token=token,
label="GitHub Copilot (gh CLI)",
)
except Exception:
pass
return None
def _extract_iflow(self) -> Optional[ExtractedToken]:
"""iFlow AI: ~/.iflow/settings.json"""
settings_path = Path.home() / ".iflow" / "settings.json"
if not settings_path.exists():
return None
try:
data = json.loads(settings_path.read_text())
api_key = data.get("apiKey") or data.get("api_key")
if api_key:
return ExtractedToken(
provider_id="iflow",
credential_type="api_key",
token=api_key,
label="iFlow AI",
)
except Exception:
pass
return None
def _extract_qwen_code(self) -> Optional[ExtractedToken]:
"""Qwen Code: ~/.qwen/oauth_creds.json"""
creds_path = Path.home() / ".qwen" / "oauth_creds.json"
if not creds_path.exists():
return None
try:
data = json.loads(creds_path.read_text())
access_token = data.get("access_token")
refresh = data.get("refresh_token")
expires = data.get("expires_at")
if access_token:
return ExtractedToken(
provider_id="qwen_code",
credential_type="oauth",
token=access_token,
refresh_token=refresh,
expires_at=expires,
label="Qwen Code",
)
except Exception:
pass
return None
def _extract_kiro(self) -> Optional[ExtractedToken]:
"""Kiro AI: SQLite data.sqlite3"""
is_mac = platform.system() == "Darwin"
if is_mac:
db_path = Path.home() / "Library" / "Application Support" / "kiro-cli" / "data.sqlite3"
else:
db_path = Path.home() / ".local" / "share" / "kiro-cli" / "data.sqlite3"
if not db_path.exists():
return None
try:
import sqlite3
conn = sqlite3.connect(str(db_path), timeout=1)
cursor = conn.cursor()
cursor.execute(
"SELECT value FROM auth_kv WHERE key = ?",
("access_token",)
)
row = cursor.fetchone()
refresh_row = None
try:
cursor.execute(
"SELECT value FROM auth_kv WHERE key = ?",
("refresh_token",)
)
refresh_row = cursor.fetchone()
except Exception:
pass
conn.close()
if row and row[0]:
return ExtractedToken(
provider_id="kiro",
credential_type="oauth",
token=row[0],
refresh_token=refresh_row[0] if refresh_row else None,
label="Kiro AI",
)
except Exception:
pass
return None
# ── Helpers ──────────────────────────────────────────────
@staticmethod
def _read_macos_keychain(service: str) -> Optional[str]:
"""Read a credential from macOS Keychain."""
if platform.system() != "Darwin":
return None
try:
result = subprocess.run(
["security", "find-generic-password", "-s", service, "-w"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except Exception:
pass
return None
@@ -0,0 +1,182 @@
"""
NeuroSploit v3 - Token Refresher
Background asyncio task that refreshes OAuth tokens before they expire.
Checks every 5 minutes, refreshes tokens expiring within 10 minutes.
"""
import asyncio
import logging
import os
import time
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from .provider_registry import ProviderRegistry
logger = logging.getLogger(__name__)
REFRESH_INTERVAL = 300 # 5 minutes
REFRESH_THRESHOLD = 600 # Refresh if expiring within 10 minutes
# Known OAuth endpoints for token refresh
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
ANTHROPIC_TOKEN_URL = "https://auth.anthropic.com/oauth/token"
OPENAI_TOKEN_URL = "https://auth0.openai.com/oauth/token"
class TokenRefresher:
"""Background task that auto-refreshes expiring OAuth tokens."""
def __init__(self, registry: "ProviderRegistry"):
self._registry = registry
self._running = False
self._task: Optional[asyncio.Task] = None
async def start(self):
"""Start the background refresh loop."""
if self._running:
return
self._running = True
self._task = asyncio.create_task(self._refresh_loop())
logger.info("TokenRefresher: Started background refresh loop")
async def stop(self):
"""Stop the background refresh loop."""
self._running = False
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info("TokenRefresher: Stopped")
async def _refresh_loop(self):
"""Main loop: check and refresh tokens periodically."""
while self._running:
try:
await self._check_and_refresh()
except Exception as e:
logger.error(f"TokenRefresher: Error in refresh loop: {e}")
await asyncio.sleep(REFRESH_INTERVAL)
async def _check_and_refresh(self):
"""Check all accounts and refresh tokens nearing expiry."""
now = time.time()
for provider in self._registry.get_all_providers():
for acct in provider.accounts.values():
if not acct.is_active or acct.credential_type != "oauth":
continue
if acct.expires_at is None:
continue
if acct.expires_at - now > REFRESH_THRESHOLD:
continue # Not expiring soon
refresh_token = self._registry.get_refresh_token(acct.id)
if not refresh_token:
logger.debug(
f"TokenRefresher: {acct.label} expiring but no refresh token"
)
continue
logger.info(
f"TokenRefresher: Refreshing {acct.label} "
f"(expires in {int(acct.expires_at - now)}s)"
)
success = await self._refresh_token(
provider_id=provider.id,
account_id=acct.id,
refresh_token=refresh_token,
)
if not success:
logger.warning(
f"TokenRefresher: Failed to refresh {acct.label}"
)
async def _refresh_token(
self, provider_id: str, account_id: str, refresh_token: str
) -> bool:
"""Attempt to refresh a token based on provider type."""
try:
if provider_id in ("claude_code", "kiro"):
return await self._refresh_anthropic(account_id, refresh_token)
elif provider_id == "codex_cli":
return await self._refresh_openai(account_id, refresh_token)
else:
logger.debug(
f"TokenRefresher: No refresh method for {provider_id}"
)
return False
except Exception as e:
logger.error(f"TokenRefresher: Refresh failed for {provider_id}: {e}")
return False
async def _refresh_anthropic(self, account_id: str, refresh_token: str) -> bool:
"""Refresh an Anthropic/Claude OAuth token."""
try:
import aiohttp
payload = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}
async with aiohttp.ClientSession() as session:
async with session.post(ANTHROPIC_TOKEN_URL, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200:
data = await resp.json()
new_token = data.get("access_token")
expires_in = data.get("expires_in", 3600)
new_refresh = data.get("refresh_token")
if new_token:
self._registry.update_credential(
account_id,
new_token,
time.time() + expires_in,
)
# Update refresh token if rotated
if new_refresh:
self._registry._refresh_tokens[account_id] = new_refresh
logger.info(f"TokenRefresher: Anthropic token refreshed")
return True
else:
body = await resp.text()
logger.warning(f"TokenRefresher: Anthropic refresh failed: {resp.status} {body[:200]}")
except ImportError:
logger.warning("TokenRefresher: aiohttp not available for token refresh")
except Exception as e:
logger.error(f"TokenRefresher: Anthropic refresh error: {e}")
return False
async def _refresh_openai(self, account_id: str, refresh_token: str) -> bool:
"""Refresh an OpenAI/Codex OAuth token."""
try:
import aiohttp
payload = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
}
async with aiohttp.ClientSession() as session:
async with session.post(OPENAI_TOKEN_URL, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 200:
data = await resp.json()
new_token = data.get("access_token")
expires_in = data.get("expires_in", 3600)
if new_token:
self._registry.update_credential(
account_id,
new_token,
time.time() + expires_in,
)
logger.info(f"TokenRefresher: OpenAI token refreshed")
return True
else:
body = await resp.text()
logger.warning(f"TokenRefresher: OpenAI refresh failed: {resp.status} {body[:200]}")
except ImportError:
logger.warning("TokenRefresher: aiohttp not available for token refresh")
except Exception as e:
logger.error(f"TokenRefresher: OpenAI refresh error: {e}")
return False