cryptorugmuncher commited on
Commit Β·
9513328
1
Parent(s): 9a1445c
feat(foundation): wire DeepSeek core/ modules into new main.py
Browse filesmain.py (88 lines) wires up:
- structlog JSON logging
- AppError handlers
- AuthMiddleware + 6 function middlewares
- core/lifespan.py asynccontextmanager (replaces deprecated on_event)
- 1249 routes preserved via _legacy_main
- v1 router mount point (strangler)
27 core/ modules from DeepSeek. Backend healthy, /health 200.
- backend/_legacy_main.py +0 -0
- backend/app/api/__init__.py +1 -0
- backend/app/api/deps.py +33 -0
- backend/app/api/v1/__init__.py +32 -0
- backend/app/api/v1/admin/__init__.py +4 -0
- backend/app/api/v1/auth/__init__.py +4 -0
- backend/app/api/v1/mcp/__init__.py +4 -0
- backend/app/api/v1/public/__init__.py +4 -0
- backend/app/api/v1/x402/__init__.py +4 -0
- backend/app/api/ws/__init__.py +4 -0
- backend/app/config.py +78 -0
- backend/app/core/__init__.py +31 -0
- backend/app/core/agent_memory.py +98 -0
- backend/app/core/ai_stream.py +80 -0
- backend/app/core/auth.py +65 -0
- backend/app/core/cerebras_provider.py +49 -0
- backend/app/core/config.py +81 -0
- backend/app/core/cost_tracker.py +111 -0
- backend/app/core/databus_extras.py +48 -0
- backend/app/core/db.py +60 -0
- backend/app/core/db_pool.py +60 -0
- backend/app/core/errors.py +65 -0
- backend/app/core/http.py +27 -0
- backend/app/core/lifespan.py +105 -0
- backend/app/core/llm_cache.py +49 -0
- backend/app/core/logging.py +40 -0
- backend/app/core/metrics.py +69 -0
- backend/app/core/middleware.py +133 -0
- backend/app/core/mistral_provider.py +106 -0
- backend/app/core/model_eval.py +128 -0
- backend/app/core/model_router.py +123 -0
- backend/app/core/prompt_registry.py +81 -0
- backend/app/core/rate_limiter.py +248 -0
- backend/app/core/redis.py +62 -0
- backend/app/core/signal_generator.py +123 -0
- backend/app/core/task_queue.py +102 -0
- backend/app/core/tracing.py +42 -0
- backend/app/core/tron_provider.py +68 -0
- backend/app/core/websocket.py +62 -0
- backend/app/models/__init__.py +23 -0
- backend/app/models/requests.py +47 -0
- backend/app/models/responses.py +56 -0
- backend/main.py +88 -0
backend/_legacy_main.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backend/app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""HTTP transport layer. Routes are thin: parse β call domain service β return."""
|
backend/app/api/deps.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared FastAPI dependencies.
|
| 2 |
+
|
| 3 |
+
Use these in route signatures to inject cross-cutting concerns:
|
| 4 |
+
from app.api.deps import get_redis, get_current_user, get_settings
|
| 5 |
+
|
| 6 |
+
Actual implementations live in `app/core/`. This module is a re-export
|
| 7 |
+
facade so route authors don't need to know which core module owns what.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
# Re-exports β actual implementations come from app/core/.
|
| 12 |
+
# Core modules are populated by the parallel DeepSeek tasks (DS-1..DS-10).
|
| 13 |
+
# Until then, these imports will fail; routes should not depend on them yet.
|
| 14 |
+
try:
|
| 15 |
+
from app.core.redis import get_redis # noqa: F401
|
| 16 |
+
except ImportError:
|
| 17 |
+
get_redis = None # type: ignore[assignment]
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
from app.core.db import get_db # noqa: F401
|
| 21 |
+
except ImportError:
|
| 22 |
+
get_db = None # type: ignore[assignment]
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
from app.core.auth import get_current_user, get_optional_user # noqa: F401
|
| 26 |
+
except ImportError:
|
| 27 |
+
get_current_user = None # type: ignore[assignment]
|
| 28 |
+
get_optional_user = None # type: ignore[assignment]
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
from app.core.config import settings # noqa: F401
|
| 32 |
+
except ImportError:
|
| 33 |
+
from app.config import settings # fallback until core.config lands
|
backend/app/api/v1/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""V1 API router aggregator.
|
| 2 |
+
|
| 3 |
+
The strangle: new v1 routes are added here as domains migrate. The legacy
|
| 4 |
+
main.py still mounts all old routes; we ADD new v1 routes on top so they
|
| 5 |
+
co-exist until cutover.
|
| 6 |
+
|
| 7 |
+
To add a new domain:
|
| 8 |
+
1. Create app/api/v1/<group>/<domain>.py with APIRouter
|
| 9 |
+
2. Import and append it to `api_v1_router` below
|
| 10 |
+
3. Mount the route prefix in the domain's __init__.py
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter
|
| 15 |
+
|
| 16 |
+
# Aggregator list β populated as domains migrate.
|
| 17 |
+
# Each entry is an APIRouter from app/api/v1/<group>/<domain>.py.
|
| 18 |
+
api_v1_router: list[APIRouter] = []
|
| 19 |
+
|
| 20 |
+
# Aggregator router β single mount point for v1.
|
| 21 |
+
# When domains migrate, replace this with a real aggregator:
|
| 22 |
+
# from app.api.v1.public import router as public_router
|
| 23 |
+
# api_v1_router.append(public_router)
|
| 24 |
+
router = APIRouter(prefix="/api/v1", tags=["v1"])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_v1_router() -> APIRouter:
|
| 28 |
+
"""Construct the v1 aggregator with all migrated routes mounted."""
|
| 29 |
+
aggregated = APIRouter(prefix="/api/v1")
|
| 30 |
+
for r in api_v1_router:
|
| 31 |
+
aggregated.include_router(r)
|
| 32 |
+
return aggregated
|
backend/app/api/v1/admin/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin routes β admin role required.
|
| 2 |
+
|
| 3 |
+
Target: user management, system config, ops, bulletin moderation.
|
| 4 |
+
"""
|
backend/app/api/v1/auth/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authenticated routes β JWT required.
|
| 2 |
+
|
| 3 |
+
Target: portfolio, alerts, intel feeds, profile, settings.
|
| 4 |
+
"""
|
backend/app/api/v1/mcp/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model Context Protocol routes.
|
| 2 |
+
|
| 3 |
+
Target: tool catalog for AI agents, JSON-RPC endpoint.
|
| 4 |
+
"""
|
backend/app/api/v1/public/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Public routes β no authentication required.
|
| 2 |
+
|
| 3 |
+
Target: scanner, wallet lookup, token info, pricing, health.
|
| 4 |
+
"""
|
backend/app/api/v1/x402/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""x402 paid routes β crypto micropayment gated.
|
| 2 |
+
|
| 3 |
+
Target: tools (split from legacy x402_tools.py), tokens, wallets, defi, security.
|
| 4 |
+
"""
|
backend/app/api/ws/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WebSocket endpoints.
|
| 2 |
+
|
| 3 |
+
Target: real-time alerts, scanner results, intel feeds.
|
| 4 |
+
"""
|
backend/app/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration.
|
| 2 |
+
|
| 3 |
+
Pydantic-settings reads from environment variables (and .env in dev).
|
| 4 |
+
Import: `from app.config import settings`
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
from typing import Literal
|
| 10 |
+
|
| 11 |
+
from pydantic import Field
|
| 12 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class Settings(BaseSettings):
|
| 16 |
+
"""All runtime configuration. Add new env vars here as needed."""
|
| 17 |
+
|
| 18 |
+
model_config = SettingsConfigDict(
|
| 19 |
+
env_file=".env",
|
| 20 |
+
env_file_encoding="utf-8",
|
| 21 |
+
case_sensitive=False,
|
| 22 |
+
extra="ignore",
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# ββ Runtime ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 26 |
+
environment: Literal["dev", "staging", "prod"] = "prod"
|
| 27 |
+
log_level: str = "INFO"
|
| 28 |
+
port: int = 8000
|
| 29 |
+
|
| 30 |
+
# ββ Database / Cache ββββββββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
database_url: str = "postgresql+asyncpg://rmi:rmi@localhost/rmi"
|
| 32 |
+
redis_url: str = "redis://localhost:6379/0"
|
| 33 |
+
|
| 34 |
+
# ββ Auth ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
jwt_secret: str = Field(default="dev-secret-CHANGE-ME")
|
| 36 |
+
jwt_algorithm: str = "HS256"
|
| 37 |
+
jwt_expire_minutes: int = 60 * 24
|
| 38 |
+
|
| 39 |
+
# ββ CORS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 40 |
+
cors_origins: list[str] = ["*"]
|
| 41 |
+
|
| 42 |
+
# ββ Rate limiting βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 43 |
+
rate_limit_per_minute: int = 100
|
| 44 |
+
|
| 45 |
+
# ββ AI providers ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
ollama_url: str = "http://localhost:11434"
|
| 47 |
+
openrouter_api_key: str = ""
|
| 48 |
+
huggingface_token: str = ""
|
| 49 |
+
|
| 50 |
+
# ββ Langfuse v4 (observability) βββββββββββββββββββββββββββββββββ
|
| 51 |
+
langfuse_public_key: str = ""
|
| 52 |
+
langfuse_secret_key: str = ""
|
| 53 |
+
langfuse_host: str = "http://localhost:3002"
|
| 54 |
+
|
| 55 |
+
# ββ External APIs βββββββββββββββββββββββββββββββββββββββββββββββ
|
| 56 |
+
coingecko_api_key: str = ""
|
| 57 |
+
etherscan_api_key: str = ""
|
| 58 |
+
birdeye_api_key: str = ""
|
| 59 |
+
goplus_api_key: str = ""
|
| 60 |
+
|
| 61 |
+
# ββ RMI-specific ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
+
rag_collections: list[str] = [
|
| 63 |
+
"scam_intel",
|
| 64 |
+
"deployer_history",
|
| 65 |
+
"wallet_labels",
|
| 66 |
+
"contract_audit",
|
| 67 |
+
"phishing_db",
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@lru_cache(maxsize=1)
|
| 72 |
+
def get_settings() -> Settings:
|
| 73 |
+
"""Cached settings instance."""
|
| 74 |
+
return Settings()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# Module-level singleton for convenience.
|
| 78 |
+
settings = get_settings()
|
backend/app/core/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-cutting concerns. NO business logic."""
|
| 2 |
+
|
| 3 |
+
from app.core.agent_memory import get_conversation, get_user_context, store_conversation
|
| 4 |
+
from app.core.ai_stream import StreamRequest, ai_route, list_providers, stream_ai
|
| 5 |
+
from app.core.auth import AuthMiddleware, is_public_path
|
| 6 |
+
from app.core.cerebras_provider import cerebras_chat
|
| 7 |
+
from app.core.config import Settings, get_settings
|
| 8 |
+
from app.core.cost_tracker import cheapest_for_task, get_cheapest_model, get_rates, get_usage, log_usage
|
| 9 |
+
from app.core.databus_extras import provider_dashboard, task_queue_stats
|
| 10 |
+
from app.core.db import get_supabase, get_supabase_sync
|
| 11 |
+
from app.core.db_pool import get_postgres, pool_stats, return_postgres
|
| 12 |
+
from app.core.errors import AppError, AuthError, NotFoundError, RateLimitError, register_error_handlers
|
| 13 |
+
from app.core.http import close_http_client
|
| 14 |
+
from app.core.lifespan import lifespan
|
| 15 |
+
from app.core.llm_cache import get_cache_stats, get_cached, set_cached
|
| 16 |
+
from app.core.logging import get_logger, setup_logging
|
| 17 |
+
from app.core.metrics import setup_metrics
|
| 18 |
+
from app.core.middleware import cache_middleware, emergency_lockdown_middleware, hsts_middleware, payload_size_limit_middleware, request_id_middleware, secure_cookie_middleware
|
| 19 |
+
from app.core.mistral_provider import mistral_chat, mistral_embed, mistral_moderate
|
| 20 |
+
from app.core.model_eval import compare_models, evaluate_model
|
| 21 |
+
from app.core.model_router import RoutingDecision, TaskType, route_task, smart_route
|
| 22 |
+
from app.core.prompt_registry import get_prompt, get_prompt_info, list_prompts, load_all_prompts, reload_prompts, render_prompt
|
| 23 |
+
from app.core.rate_limiter import Tier, UpgradeRequest, check_rate_limit, get_tiers, get_user_tier, my_tier, payment_links, upgrade_tier
|
| 24 |
+
from app.core.redis import get_redis, get_redis_async, invalidate_redis
|
| 25 |
+
from app.core.signal_generator import fetch_trending, main, publish_signal, scan_and_signal
|
| 26 |
+
from app.core.task_queue import enqueue, get_queue_stats, process_tasks, register_task
|
| 27 |
+
from app.core.tracing import end_span, setup_tracing, start_span
|
| 28 |
+
from app.core.tron_provider import tron_balance, tron_transactions
|
| 29 |
+
from app.core.websocket import active_connections, broadcast_alert, broadcast_scan, register_connection, unregister_connection
|
| 30 |
+
|
| 31 |
+
__all__ = ['AppError', 'AuthError', 'AuthMiddleware', 'NotFoundError', 'RateLimitError', 'RoutingDecision', 'Settings', 'StreamRequest', 'TaskType', 'Tier', 'UpgradeRequest', 'active_connections', 'ai_route', 'broadcast_alert', 'broadcast_scan', 'cache_middleware', 'cerebras_chat', 'cheapest_for_task', 'check_rate_limit', 'close_http_client', 'compare_models', 'emergency_lockdown_middleware', 'end_span', 'enqueue', 'evaluate_model', 'fetch_trending', 'get_cache_stats', 'get_cached', 'get_cheapest_model', 'get_conversation', 'get_logger', 'get_postgres', 'get_prompt', 'get_prompt_info', 'get_queue_stats', 'get_rates', 'get_redis', 'get_redis', 'get_redis_async', 'get_settings', 'get_supabase', 'get_supabase_sync', 'get_tiers', 'get_usage', 'get_user_context', 'get_user_tier', 'hsts_middleware', 'invalidate_redis', 'is_public_path', 'lifespan', 'list_prompts', 'list_providers', 'load_all_prompts', 'log_usage', 'main', 'mistral_chat', 'mistral_embed', 'mistral_moderate', 'my_tier', 'payload_size_limit_middleware', 'payment_links', 'pool_stats', 'process_tasks', 'provider_dashboard', 'publish_signal', 'register_connection', 'register_error_handlers', 'register_task', 'reload_prompts', 'render_prompt', 'request_id_middleware', 'return_postgres', 'route_task', 'scan_and_signal', 'secure_cookie_middleware', 'set_cached', 'setup_logging', 'setup_metrics', 'setup_tracing', 'smart_route', 'start_span', 'store_conversation', 'stream_ai', 'task_queue_stats', 'tron_balance', 'tron_transactions', 'unregister_connection', 'upgrade_tier']
|
backend/app/core/agent_memory.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""#9 β Agent Memory Layer. Stores conversation history in Memgraph for long-term agent memory.
|
| 2 |
+
Enables agents to remember past interactions across sessions."""
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from datetime import UTC, datetime
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter
|
| 8 |
+
|
| 9 |
+
MEMGRAPH_URI = os.getenv("MEMGRAPH_URI", "bolt://localhost:7687")
|
| 10 |
+
MEMGRAPH_USER = os.getenv("MEMGRAPH_USER", "")
|
| 11 |
+
MEMGRAPH_PASS = os.getenv("MEMGRAPH_PASSWORD", "")
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/api/v1/memory", tags=["agent-memory"])
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def _run_query(query: str, params: dict = None) -> list:
|
| 17 |
+
"""Run a Cypher query against Memgraph."""
|
| 18 |
+
import requests
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
r = requests.post(
|
| 22 |
+
"http://localhost:7444/db/memgraph/query",
|
| 23 |
+
json={"query": query, "parameters": params or {}},
|
| 24 |
+
headers={"Content-Type": "application/json"},
|
| 25 |
+
timeout=10,
|
| 26 |
+
)
|
| 27 |
+
if r.status_code == 200:
|
| 28 |
+
return r.json().get("data", [])
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
return []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("/conversation")
|
| 35 |
+
async def store_conversation(user_id: str, agent_id: str, message: str, role: str = "user"):
|
| 36 |
+
"""Store a conversation message in agent memory graph."""
|
| 37 |
+
query = """
|
| 38 |
+
MERGE (u:User {id: $user_id})
|
| 39 |
+
MERGE (a:Agent {id: $agent_id})
|
| 40 |
+
MERGE (c:Conversation {id: $conv_id})
|
| 41 |
+
ON CREATE SET c.created_at = $timestamp
|
| 42 |
+
CREATE (m:Message {
|
| 43 |
+
role: $role,
|
| 44 |
+
content: $message,
|
| 45 |
+
timestamp: $timestamp
|
| 46 |
+
})
|
| 47 |
+
MERGE (u)-[:PARTICIPATES_IN]->(c)
|
| 48 |
+
MERGE (a)-[:PARTICIPATES_IN]->(c)
|
| 49 |
+
MERGE (c)-[:HAS_MESSAGE]->(m)
|
| 50 |
+
MERGE (m)-[:SENT_BY]->(CASE WHEN $role = 'user' THEN u ELSE a END)
|
| 51 |
+
"""
|
| 52 |
+
conv_id = f"{user_id}:{agent_id}"
|
| 53 |
+
await _run_query(
|
| 54 |
+
query,
|
| 55 |
+
{
|
| 56 |
+
"user_id": user_id,
|
| 57 |
+
"agent_id": agent_id,
|
| 58 |
+
"conv_id": conv_id,
|
| 59 |
+
"role": role,
|
| 60 |
+
"message": message,
|
| 61 |
+
"timestamp": datetime.now(UTC).isoformat(),
|
| 62 |
+
},
|
| 63 |
+
)
|
| 64 |
+
return {"stored": True, "conversation_id": conv_id}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.get("/conversation/{user_id}/{agent_id}")
|
| 68 |
+
async def get_conversation(user_id: str, agent_id: str, limit: int = 20):
|
| 69 |
+
"""Retrieve conversation history for an agent."""
|
| 70 |
+
query = """
|
| 71 |
+
MATCH (u:User {id: $user_id})-[:PARTICIPATES_IN]->(c:Conversation)<-[:PARTICIPATES_IN]-(a:Agent {id: $agent_id})
|
| 72 |
+
MATCH (c)-[:HAS_MESSAGE]->(m:Message)
|
| 73 |
+
RETURN m.role as role, m.content as content, m.timestamp as timestamp
|
| 74 |
+
ORDER BY m.timestamp DESC LIMIT $limit
|
| 75 |
+
"""
|
| 76 |
+
rows = await _run_query(query, {"user_id": user_id, "agent_id": agent_id, "limit": limit})
|
| 77 |
+
return {
|
| 78 |
+
"user_id": user_id,
|
| 79 |
+
"agent_id": agent_id,
|
| 80 |
+
"messages": [{"role": r[0], "content": r[1], "timestamp": r[2]} for r in reversed(rows)] if rows else [],
|
| 81 |
+
"count": len(rows) if rows else 0,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get("/context/{user_id}")
|
| 86 |
+
async def get_user_context(user_id: str):
|
| 87 |
+
"""Get all agent conversations + preferences for a user."""
|
| 88 |
+
query = """
|
| 89 |
+
MATCH (u:User {id: $user_id})-[:PARTICIPATES_IN]->(c:Conversation)
|
| 90 |
+
MATCH (a:Agent)-[:PARTICIPATES_IN]->(c)
|
| 91 |
+
RETURN a.id as agent, count(*) as messages
|
| 92 |
+
ORDER BY messages DESC
|
| 93 |
+
"""
|
| 94 |
+
rows = await _run_query(query, {"user_id": user_id})
|
| 95 |
+
return {
|
| 96 |
+
"user_id": user_id,
|
| 97 |
+
"agents": [{"agent": r[0], "messages": r[1]} for r in rows] if rows else [],
|
| 98 |
+
}
|
backend/app/core/ai_stream.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SSE Response Streaming β real-time token streaming for AI endpoints."""
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
from fastapi.responses import StreamingResponse
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from app.core.model_router import route_task, TaskType
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api/v1/ai", tags=["ai-streaming"])
|
| 10 |
+
|
| 11 |
+
class StreamRequest(BaseModel):
|
| 12 |
+
prompt: str
|
| 13 |
+
task: str = "fast" # fast|cheap|complex|bulk|code
|
| 14 |
+
prefer: str = "fast" # fast|cheap
|
| 15 |
+
|
| 16 |
+
async def _stream_ollama(prompt: str, model: str):
|
| 17 |
+
"""Stream from Ollama."""
|
| 18 |
+
import httpx
|
| 19 |
+
async with httpx.AsyncClient(timeout=120) as c:
|
| 20 |
+
async with c.stream("POST", "http://localhost:11434/api/generate", json={
|
| 21 |
+
"model": model, "prompt": prompt
|
| 22 |
+
}) as r:
|
| 23 |
+
async for line in r.aiter_lines():
|
| 24 |
+
if line:
|
| 25 |
+
try:
|
| 26 |
+
chunk = json.loads(line)
|
| 27 |
+
if chunk.get("done"):
|
| 28 |
+
yield f"data: {json.dumps({'done': True, 'model': model})}\n\n"
|
| 29 |
+
break
|
| 30 |
+
yield f"data: {json.dumps({'token': chunk.get('response', '')})}\n\n"
|
| 31 |
+
except json.JSONDecodeError:
|
| 32 |
+
continue
|
| 33 |
+
|
| 34 |
+
@router.post("/stream")
|
| 35 |
+
async def stream_ai(req: StreamRequest):
|
| 36 |
+
"""Stream AI response in real-time. Tokens appear as generated."""
|
| 37 |
+
decision = route_task(TaskType(req.task), req.prefer)
|
| 38 |
+
|
| 39 |
+
if decision.provider == "ollama":
|
| 40 |
+
return StreamingResponse(
|
| 41 |
+
_stream_ollama(req.prompt, decision.model),
|
| 42 |
+
media_type="text/event-stream",
|
| 43 |
+
headers={"X-Model": decision.model, "X-Provider": decision.provider, "X-Latency-Ms": str(decision.estimated_latency_ms)}
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# For non-Ollama providers, fall back to blocking + stream result
|
| 47 |
+
async def _blocking_stream():
|
| 48 |
+
from app.core.model_router import smart_route
|
| 49 |
+
result = await smart_route(req.prompt, req.task, req.prefer)
|
| 50 |
+
if result and "response" in result:
|
| 51 |
+
words = result["response"].split()
|
| 52 |
+
for word in words:
|
| 53 |
+
yield f"data: {json.dumps({'token': word + ' '})}\n\n"
|
| 54 |
+
await asyncio.sleep(0.05)
|
| 55 |
+
yield f"data: {json.dumps({'done': True, 'model': decision.model})}\n\n"
|
| 56 |
+
|
| 57 |
+
return StreamingResponse(
|
| 58 |
+
_blocking_stream(),
|
| 59 |
+
media_type="text/event-stream",
|
| 60 |
+
headers={"X-Model": decision.model, "X-Provider": decision.provider}
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
@router.post("/route")
|
| 64 |
+
async def ai_route(req: StreamRequest):
|
| 65 |
+
"""Non-streaming: auto-route to best model, return complete response."""
|
| 66 |
+
from app.core.model_router import smart_route
|
| 67 |
+
result = await smart_route(req.prompt, req.task, req.prefer)
|
| 68 |
+
return result if result else {"error": "All providers failed"}
|
| 69 |
+
|
| 70 |
+
@router.get("/providers")
|
| 71 |
+
async def list_providers():
|
| 72 |
+
"""List all available AI providers with capabilities."""
|
| 73 |
+
from app.core.model_router import ROUTING_TABLE
|
| 74 |
+
return {
|
| 75 |
+
"providers": {
|
| 76 |
+
task.value: [{"model": m[0], "provider": m[1], "latency_ms": m[2]*1000, "cost_per_1M_input": m[3]}
|
| 77 |
+
for m in models]
|
| 78 |
+
for task, models in ROUTING_TABLE.items()
|
| 79 |
+
}
|
| 80 |
+
}
|
backend/app/core/auth.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend β Auth middleware and API key verification."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
from fastapi import Request
|
| 6 |
+
from fastapi.responses import JSONResponse
|
| 7 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 8 |
+
|
| 9 |
+
RMI_AUTH_TOKEN = os.getenv("RMI_AUTH_TOKEN", "")
|
| 10 |
+
|
| 11 |
+
PUBLIC_WRITE_PREFIXES = [
|
| 12 |
+
"/api/v1/auth/",
|
| 13 |
+
"/api/v1/x402/",
|
| 14 |
+
"/api/v1/x402-tools/",
|
| 15 |
+
"/api/v1/x402-databus/",
|
| 16 |
+
"/api/v1/databus/",
|
| 17 |
+
"/api/v1/alerts/",
|
| 18 |
+
"/api/v1/admin/",
|
| 19 |
+
"/api/v1/content/",
|
| 20 |
+
"/api/v1/bulletin/",
|
| 21 |
+
"/api/v1/rag/permanence/",
|
| 22 |
+
"/api/v1/token/",
|
| 23 |
+
"/api/v1/ai/",
|
| 24 |
+
"/api/v1/premium/",
|
| 25 |
+
"/api/v1/rag/",
|
| 26 |
+
"/api/v1/protect/",
|
| 27 |
+
"/api/v1/wallet-manager/",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
# Auth bypass paths
|
| 31 |
+
PUBLIC_GET_PREFIXES = [
|
| 32 |
+
"/api/v1/token/",
|
| 33 |
+
"/api/v1/databus/",
|
| 34 |
+
"/api/v1/alerts/",
|
| 35 |
+
"/api/v1/x402-databus/",
|
| 36 |
+
"/api/v1/x402-tools/",
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def is_public_path(path: str, method: str) -> bool:
|
| 41 |
+
"""Check if a path is publicly accessible without auth."""
|
| 42 |
+
if path in ("/health", "/ready", "/docs", "/openapi.json", "/redoc", "/", "/favicon.ico"):
|
| 43 |
+
return True
|
| 44 |
+
if path.startswith("/ws/") or not path.startswith("/api/"):
|
| 45 |
+
return True
|
| 46 |
+
if method in ("POST", "PUT", "DELETE", "PATCH"):
|
| 47 |
+
return any(path.startswith(p) for p in PUBLIC_WRITE_PREFIXES)
|
| 48 |
+
return True # GET/HEAD always public
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class AuthMiddleware(BaseHTTPMiddleware):
|
| 52 |
+
async def dispatch(self, request: Request, call_next):
|
| 53 |
+
path = request.url.path
|
| 54 |
+
method = request.method
|
| 55 |
+
|
| 56 |
+
if is_public_path(path, method):
|
| 57 |
+
return await call_next(request)
|
| 58 |
+
|
| 59 |
+
api_key = request.headers.get("X-API-Key", "")
|
| 60 |
+
if RMI_AUTH_TOKEN and api_key != RMI_AUTH_TOKEN:
|
| 61 |
+
return JSONResponse(
|
| 62 |
+
status_code=401,
|
| 63 |
+
content={"detail": "Unauthorized - valid X-API-Key header required for write operations"},
|
| 64 |
+
)
|
| 65 |
+
return await call_next(request)
|
backend/app/core/cerebras_provider.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cerebras provider β GPT-OSS-120B, fastest inference on Earth (9ms).
|
| 2 |
+
Free tier: 14,400 req/day, 1M tokens/day."""
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
CEREBRAS_KEY = os.getenv("CEREBRAS_API_KEY", "")
|
| 11 |
+
BASE = "https://api.cerebras.ai/v1"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def cerebras_chat(
|
| 15 |
+
prompt: str, system: str = None, temperature: float = 0.7, max_tokens: int = 1024
|
| 16 |
+
) -> dict | None:
|
| 17 |
+
"""GPT-OSS-120B via Cerebras β 9ms latency. Use for real-time, latency-sensitive tasks."""
|
| 18 |
+
if not CEREBRAS_KEY:
|
| 19 |
+
return None
|
| 20 |
+
messages = []
|
| 21 |
+
if system:
|
| 22 |
+
messages.append({"role": "system", "content": system})
|
| 23 |
+
messages.append({"role": "user", "content": prompt})
|
| 24 |
+
try:
|
| 25 |
+
async with httpx.AsyncClient(timeout=15) as c:
|
| 26 |
+
r = await c.post(
|
| 27 |
+
f"{BASE}/chat/completions",
|
| 28 |
+
json={
|
| 29 |
+
"model": "gpt-oss-120b",
|
| 30 |
+
"messages": messages,
|
| 31 |
+
"max_tokens": max_tokens,
|
| 32 |
+
"temperature": temperature,
|
| 33 |
+
},
|
| 34 |
+
headers={"Authorization": f"Bearer {CEREBRAS_KEY}"},
|
| 35 |
+
)
|
| 36 |
+
if r.status_code == 200:
|
| 37 |
+
d = r.json()
|
| 38 |
+
choice = d["choices"][0]["message"]
|
| 39 |
+
content = choice.get("content") or choice.get("reasoning", "")
|
| 40 |
+
return {
|
| 41 |
+
"response": content.strip(),
|
| 42 |
+
"model": "gpt-oss-120b",
|
| 43 |
+
"tokens": d.get("usage", {}).get("total_tokens", 0),
|
| 44 |
+
"latency_ms": round(d.get("time_info", {}).get("total_time", 0) * 1000),
|
| 45 |
+
"provider": "cerebras",
|
| 46 |
+
}
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.warning(f"Cerebras failed: {e}")
|
| 49 |
+
return None
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Central configuration β single source of truth for all settings.
|
| 3 |
+
Loaded from .env via pydantic-settings.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
print(settings.REDIS_HOST)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
from functools import lru_cache
|
| 14 |
+
from typing import Optional
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class Settings:
|
| 18 |
+
"""Application settings loaded from environment variables."""
|
| 19 |
+
|
| 20 |
+
def __init__(self) -> None:
|
| 21 |
+
# Redis
|
| 22 |
+
self.REDIS_HOST: str = os.getenv("REDIS_HOST", "rmi-redis")
|
| 23 |
+
self.REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
|
| 24 |
+
self.REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD", "")
|
| 25 |
+
self.REDIS_DB: int = int(os.getenv("REDIS_DB", "0"))
|
| 26 |
+
|
| 27 |
+
# Supabase
|
| 28 |
+
self.SUPABASE_URL: str = os.getenv("SUPABASE_URL", "")
|
| 29 |
+
self.SUPABASE_SERVICE_KEY: str = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
| 30 |
+
|
| 31 |
+
# Admin
|
| 32 |
+
self.ADMIN_API_KEY: str = os.getenv("ADMIN_API_KEY", "dev-key-change-me")
|
| 33 |
+
|
| 34 |
+
# API Keys
|
| 35 |
+
self.OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
|
| 36 |
+
self.ETHERSCAN_API_KEY: str = os.getenv("ETHERSCAN_API_KEY", "")
|
| 37 |
+
self.HELIUS_API_KEY: str = os.getenv("HELIUS_API_KEY", "")
|
| 38 |
+
self.DEEPSEEK_API_KEY: str = os.getenv("DEEPSEEK_API_KEY", "")
|
| 39 |
+
self.GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY", "")
|
| 40 |
+
self.GEMINI_API_KEY_2: str = os.getenv("GEMINI_API_KEY_2", "")
|
| 41 |
+
|
| 42 |
+
# Langfuse
|
| 43 |
+
self.LANGFUSE_PUBLIC_KEY: str = os.getenv("LANGFUSE_PUBLIC_KEY", "")
|
| 44 |
+
self.LANGFUSE_SECRET_KEY: str = os.getenv("LANGFUSE_SECRET_KEY", "")
|
| 45 |
+
self.LANGFUSE_HOST: str = os.getenv("LANGFUSE_HOST", "http://langfuse-langfuse-web-1:3000")
|
| 46 |
+
|
| 47 |
+
# Ollama
|
| 48 |
+
self.OLLAMA_HOST: str = os.getenv("OLLAMA_HOST", "http://ollama:11434")
|
| 49 |
+
|
| 50 |
+
# Wallet
|
| 51 |
+
self.WALLET_VAULT_PASSWORD: str = os.getenv("WALLET_VAULT_PASSWORD", "")
|
| 52 |
+
|
| 53 |
+
# R2
|
| 54 |
+
self.R2_API_TOKEN: str = os.getenv("R2_API_TOKEN", "")
|
| 55 |
+
self.R2_ACCOUNT_ID: str = os.getenv("R2_ACCOUNT_ID", "")
|
| 56 |
+
self.R2_BUCKET: str = os.getenv("R2_BUCKET", "rag-backup")
|
| 57 |
+
|
| 58 |
+
def validate(self) -> list[str]:
|
| 59 |
+
"""Return list of missing critical env vars. Empty list = all good."""
|
| 60 |
+
critical = [
|
| 61 |
+
"REDIS_HOST",
|
| 62 |
+
"SUPABASE_URL",
|
| 63 |
+
"SUPABASE_SERVICE_KEY",
|
| 64 |
+
"ADMIN_API_KEY",
|
| 65 |
+
"WALLET_VAULT_PASSWORD",
|
| 66 |
+
]
|
| 67 |
+
missing = []
|
| 68 |
+
for var in critical:
|
| 69 |
+
if not getattr(self, var, None):
|
| 70 |
+
missing.append(var)
|
| 71 |
+
return missing
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@lru_cache(maxsize=1)
|
| 75 |
+
def get_settings() -> Settings:
|
| 76 |
+
"""Cached singleton β call this, don't instantiate Settings directly."""
|
| 77 |
+
return Settings()
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# Module-level singleton β import this everywhere
|
| 81 |
+
settings = get_settings()
|
backend/app/core/cost_tracker.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""#10 β Cost-Per-Model Tracking. Tracks $/1K tokens per model/provider.
|
| 2 |
+
Auto-routes to cheapest model that meets quality threshold."""
|
| 3 |
+
|
| 4 |
+
from datetime import UTC, datetime
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Query
|
| 7 |
+
|
| 8 |
+
router = APIRouter(prefix="/api/v1/costs", tags=["cost-tracking"])
|
| 9 |
+
|
| 10 |
+
# Cost per 1M tokens (USD) β updated June 2026
|
| 11 |
+
MODEL_COSTS = {
|
| 12 |
+
"deepseek-v4-flash": {"input": 0.14, "output": 0.28, "provider": "deepseek"},
|
| 13 |
+
"deepseek-v4-pro": {"input": 0.55, "output": 2.19, "provider": "deepseek"},
|
| 14 |
+
# Gemini pricing (paid tier, per 1M tokens)
|
| 15 |
+
"gemini-2.5-flash": {"input": 0.15, "output": 0.60, "provider": "gemini"},
|
| 16 |
+
"gemini-2.5-pro": {"input": 1.25, "output": 10.00, "provider": "gemini"},
|
| 17 |
+
"gemini-3.5-flash": {"input": 1.50, "output": 9.00, "provider": "gemini"},
|
| 18 |
+
"mistral-small-latest": {"input": 0.0, "output": 0.0, "provider": "mistral", "note": "Free tier β 1B tokens/mo"},
|
| 19 |
+
"mistral-medium-latest": {"input": 0.0, "output": 0.0, "provider": "mistral", "note": "Free tier β use sparingly"},
|
| 20 |
+
"mistral-embed": {
|
| 21 |
+
"input": 0.0,
|
| 22 |
+
"output": 0.0,
|
| 23 |
+
"provider": "mistral",
|
| 24 |
+
"note": "Free tier β state of art embeddings",
|
| 25 |
+
},
|
| 26 |
+
"mistral-large": {"input": 2.00, "output": 6.00, "provider": "mistral"},
|
| 27 |
+
"mistral-small": {"input": 0.20, "output": 0.60, "provider": "mistral"},
|
| 28 |
+
"qwen2.5-coder:7b": {"input": 0.0, "output": 0.0, "provider": "ollama"},
|
| 29 |
+
"gpt-oss-120b": {
|
| 30 |
+
"input": 0.0,
|
| 31 |
+
"output": 0.0,
|
| 32 |
+
"provider": "cerebras",
|
| 33 |
+
"note": "Free tier β 14.4K req/day, 9ms latency",
|
| 34 |
+
},
|
| 35 |
+
"mistral:7b": {"input": 0.0, "output": 0.0, "provider": "ollama"},
|
| 36 |
+
"bge-m3": {"input": 0.0, "output": 0.0, "provider": "ollama"},
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
_usage_log: list[dict] = []
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def log_usage(model: str, input_tokens: int, output_tokens: int, latency_ms: float):
|
| 43 |
+
"""Log model usage for cost tracking."""
|
| 44 |
+
costs = MODEL_COSTS.get(model, {"input": 0, "output": 0, "provider": "unknown"})
|
| 45 |
+
input_cost = (input_tokens / 1_000_000) * costs["input"]
|
| 46 |
+
output_cost = (output_tokens / 1_000_000) * costs["output"]
|
| 47 |
+
total_cost = input_cost + output_cost
|
| 48 |
+
|
| 49 |
+
_usage_log.append(
|
| 50 |
+
{
|
| 51 |
+
"timestamp": datetime.now(UTC).isoformat(),
|
| 52 |
+
"model": model,
|
| 53 |
+
"provider": costs["provider"],
|
| 54 |
+
"input_tokens": input_tokens,
|
| 55 |
+
"output_tokens": output_tokens,
|
| 56 |
+
"cost_usd": round(total_cost, 6),
|
| 57 |
+
"latency_ms": latency_ms,
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def get_cheapest_model(models: list[str]) -> str:
|
| 63 |
+
"""Pick the cheapest model from a list that meets quality."""
|
| 64 |
+
best = None
|
| 65 |
+
best_cost = float("inf")
|
| 66 |
+
for m in models:
|
| 67 |
+
c = MODEL_COSTS.get(m, {})
|
| 68 |
+
cost = c.get("output", 1.0)
|
| 69 |
+
if cost < best_cost:
|
| 70 |
+
best_cost = cost
|
| 71 |
+
best = m
|
| 72 |
+
return best or models[0]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@router.get("/rates")
|
| 76 |
+
async def get_rates():
|
| 77 |
+
"""Get current model pricing."""
|
| 78 |
+
return {"models": MODEL_COSTS, "updated": "2026-06-15"}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.get("/usage")
|
| 82 |
+
async def get_usage(limit: int = Query(50, le=200)):
|
| 83 |
+
"""Get recent usage log."""
|
| 84 |
+
recent = _usage_log[-limit:]
|
| 85 |
+
total_cost = sum(e["cost_usd"] for e in recent)
|
| 86 |
+
total_tokens = sum(e["input_tokens"] + e["output_tokens"] for e in recent)
|
| 87 |
+
return {
|
| 88 |
+
"total_cost_usd": round(total_cost, 4),
|
| 89 |
+
"total_tokens": total_tokens,
|
| 90 |
+
"entries": len(recent),
|
| 91 |
+
"log": recent,
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.get("/cheapest")
|
| 96 |
+
async def cheapest_for_task(quality: str = Query("medium", description="minimum quality tier")):
|
| 97 |
+
"""Get cheapest model for a given quality tier."""
|
| 98 |
+
if quality == "high":
|
| 99 |
+
candidates = ["deepseek-v4-pro", "gemini-2.5-pro", "mistral-large"]
|
| 100 |
+
elif quality == "medium":
|
| 101 |
+
candidates = ["deepseek-v4-flash", "gemini-2.5-flash", "mistral-small", "qwen2.5-coder:7b"]
|
| 102 |
+
else:
|
| 103 |
+
candidates = ["qwen2.5-coder:7b", "mistral:7b", "deepseek-v4-flash"]
|
| 104 |
+
|
| 105 |
+
cheapest = get_cheapest_model(candidates)
|
| 106 |
+
return {
|
| 107 |
+
"quality_tier": quality,
|
| 108 |
+
"candidates": candidates,
|
| 109 |
+
"cheapest": cheapest,
|
| 110 |
+
"cost_per_1M_output": MODEL_COSTS.get(cheapest, {}).get("output", "?"),
|
| 111 |
+
}
|
backend/app/core/databus_extras.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""#2 SSE Streaming + #3 Provider Dashboard endpoints."""
|
| 2 |
+
from fastapi import APIRouter
|
| 3 |
+
import httpx
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
router = APIRouter(prefix="/api/v1/databus", tags=["databus-extras"])
|
| 7 |
+
|
| 8 |
+
BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000")
|
| 9 |
+
|
| 10 |
+
@router.get("/providers/dashboard")
|
| 11 |
+
async def provider_dashboard():
|
| 12 |
+
"""Real-time provider health dashboard data β feed into Grafana."""
|
| 13 |
+
try:
|
| 14 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 15 |
+
r = await c.get(f"{BACKEND}/api/v1/databus/providers/health", headers={"X-RMI-Key": os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026")})
|
| 16 |
+
if r.status_code == 200:
|
| 17 |
+
data = r.json()
|
| 18 |
+
providers = data.get("providers", data)
|
| 19 |
+
|
| 20 |
+
# Format for Grafana
|
| 21 |
+
panels = []
|
| 22 |
+
for name, health in (providers.items() if isinstance(providers, dict) else []):
|
| 23 |
+
panels.append({
|
| 24 |
+
"provider": name,
|
| 25 |
+
"status": "healthy" if health.get("healthy", True) else "degraded",
|
| 26 |
+
"latency_ms": health.get("avg_latency_ms", 0),
|
| 27 |
+
"error_rate": health.get("error_rate", 0),
|
| 28 |
+
"circuit": health.get("circuit_state", "closed"),
|
| 29 |
+
})
|
| 30 |
+
|
| 31 |
+
return {
|
| 32 |
+
"providers": panels,
|
| 33 |
+
"summary": {
|
| 34 |
+
"total": len(panels),
|
| 35 |
+
"healthy": sum(1 for p in panels if p["status"] == "healthy"),
|
| 36 |
+
"degraded": sum(1 for p in panels if p["status"] != "healthy"),
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
except Exception:
|
| 40 |
+
pass
|
| 41 |
+
|
| 42 |
+
return {"providers": [], "note": "Provider health API unavailable β check backend"}
|
| 43 |
+
|
| 44 |
+
@router.get("/queue/stats")
|
| 45 |
+
async def task_queue_stats():
|
| 46 |
+
"""Background task queue statistics."""
|
| 47 |
+
from app.core.task_queue import get_queue_stats
|
| 48 |
+
return await get_queue_stats()
|
backend/app/core/db.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Supabase client singleton β single source of truth.
|
| 3 |
+
Replaces scattered supabase client creation across routers.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
from app.core.db import get_supabase
|
| 7 |
+
client = await get_supabase()
|
| 8 |
+
result = await client.table("alerts").select("*").execute()
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
_client: Any = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def get_supabase() -> Any:
|
| 23 |
+
"""Get or create the async Supabase client."""
|
| 24 |
+
global _client
|
| 25 |
+
if _client is not None:
|
| 26 |
+
return _client
|
| 27 |
+
|
| 28 |
+
url = os.getenv("SUPABASE_URL", "")
|
| 29 |
+
key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
| 30 |
+
|
| 31 |
+
if not url or not key:
|
| 32 |
+
logger.warning("supabase_not_configured")
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
from supabase import create_client as _create_sync
|
| 37 |
+
_client = _create_sync(url, key)
|
| 38 |
+
logger.info("supabase_connected", url=url[:30])
|
| 39 |
+
return _client
|
| 40 |
+
except ImportError:
|
| 41 |
+
logger.error("supabase_package_missing β pip install supabase")
|
| 42 |
+
return None
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error("supabase_init_failed", error=str(e))
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_supabase_sync() -> Any:
|
| 49 |
+
"""Synchronous Supabase client for scripts and non-async contexts."""
|
| 50 |
+
url = os.getenv("SUPABASE_URL", "")
|
| 51 |
+
key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
| 52 |
+
|
| 53 |
+
if not url or not key:
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
from supabase import create_client
|
| 58 |
+
return create_client(url, key)
|
| 59 |
+
except (ImportError, Exception):
|
| 60 |
+
return None
|
backend/app/core/db_pool.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database connection pooling β Redis + Postgres with auto-reconnect."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
| 9 |
+
PG_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres")
|
| 10 |
+
|
| 11 |
+
_redis_pool = None
|
| 12 |
+
_pg_pool = None
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_redis():
|
| 16 |
+
"""Get or create Redis connection pool. Auto-reconnects."""
|
| 17 |
+
global _redis_pool
|
| 18 |
+
if _redis_pool is None:
|
| 19 |
+
import redis
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
_redis_pool = redis.ConnectionPool.from_url(
|
| 23 |
+
REDIS_URL, max_connections=20, retry_on_timeout=True, health_check_interval=30
|
| 24 |
+
)
|
| 25 |
+
logger.info("Redis pool created (max 20)")
|
| 26 |
+
except Exception as e:
|
| 27 |
+
logger.error(f"Redis pool failed: {e}")
|
| 28 |
+
return None
|
| 29 |
+
import redis
|
| 30 |
+
|
| 31 |
+
return redis.Redis(connection_pool=_redis_pool)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_postgres():
|
| 35 |
+
"""Get or create Postgres connection pool."""
|
| 36 |
+
global _pg_pool
|
| 37 |
+
if _pg_pool is None:
|
| 38 |
+
try:
|
| 39 |
+
from psycopg2 import pool
|
| 40 |
+
|
| 41 |
+
_pg_pool = pool.ThreadedConnectionPool(5, 20, PG_URL)
|
| 42 |
+
logger.info("Postgres pool created (5-20)")
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Postgres pool failed: {e}")
|
| 45 |
+
return None
|
| 46 |
+
return _pg_pool.getconn()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def return_postgres(conn):
|
| 50 |
+
"""Return connection to pool."""
|
| 51 |
+
if _pg_pool and conn:
|
| 52 |
+
_pg_pool.putconn(conn)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def pool_stats() -> dict:
|
| 56 |
+
"""Get connection pool statistics."""
|
| 57 |
+
return {
|
| 58 |
+
"redis": {"pool_size": 20, "active": "unknown"} if _redis_pool else {"error": "no pool"},
|
| 59 |
+
"postgres": {"min": 5, "max": 20} if _pg_pool else {"error": "no pool"},
|
| 60 |
+
}
|
backend/app/core/errors.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
class AppError(Exception):
|
| 3 |
+
pass
|
| 4 |
+
|
| 5 |
+
class NotFoundError(AppError):
|
| 6 |
+
pass
|
| 7 |
+
|
| 8 |
+
class AuthError(AppError):
|
| 9 |
+
pass
|
| 10 |
+
|
| 11 |
+
class RateLimitError(AppError):
|
| 12 |
+
pass
|
| 13 |
+
"""RMI Backend - Global error handlers with structured responses.
|
| 14 |
+
|
| 15 |
+
Registers FastAPI exception handlers that return consistent JSON error responses
|
| 16 |
+
with request IDs, tracebacks (dev only), and error codes.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import traceback
|
| 20 |
+
import uuid
|
| 21 |
+
|
| 22 |
+
from fastapi import FastAPI, Request
|
| 23 |
+
from fastapi.responses import JSONResponse
|
| 24 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def register_error_handlers(app: FastAPI, debug: bool = False) -> None:
|
| 28 |
+
"""Register global exception handlers on the FastAPI app."""
|
| 29 |
+
|
| 30 |
+
@app.exception_handler(StarletteHTTPException)
|
| 31 |
+
async def http_exception_handler(request: Request, exc: StarletteHTTPException):
|
| 32 |
+
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
| 33 |
+
return JSONResponse(
|
| 34 |
+
status_code=exc.status_code,
|
| 35 |
+
content={
|
| 36 |
+
"error": exc.detail,
|
| 37 |
+
"code": exc.status_code,
|
| 38 |
+
"request_id": request_id,
|
| 39 |
+
},
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
@app.exception_handler(Exception)
|
| 43 |
+
async def unhandled_exception_handler(request: Request, exc: Exception):
|
| 44 |
+
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
| 45 |
+
response = {
|
| 46 |
+
"error": "Internal server error",
|
| 47 |
+
"code": 500,
|
| 48 |
+
"request_id": request_id,
|
| 49 |
+
}
|
| 50 |
+
if debug:
|
| 51 |
+
response["traceback"] = traceback.format_exc().split("\n")
|
| 52 |
+
response["error"] = str(exc)
|
| 53 |
+
return JSONResponse(status_code=500, content=response)
|
| 54 |
+
|
| 55 |
+
@app.exception_handler(ValueError)
|
| 56 |
+
async def value_error_handler(request: Request, exc: ValueError):
|
| 57 |
+
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
|
| 58 |
+
return JSONResponse(
|
| 59 |
+
status_code=400,
|
| 60 |
+
content={
|
| 61 |
+
"error": str(exc),
|
| 62 |
+
"code": 400,
|
| 63 |
+
"request_id": request_id,
|
| 64 |
+
},
|
| 65 |
+
)
|
backend/app/core/http.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared HTTP client with connection pooling.
|
| 3 |
+
Use this instead of creating ad-hoc httpx clients.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
from app.core.http import http_client
|
| 7 |
+
resp = await http_client.get("https://api.example.com/data")
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
# Single pooled client β reuse across all requests
|
| 15 |
+
http_client = httpx.AsyncClient(
|
| 16 |
+
limits=httpx.Limits(
|
| 17 |
+
max_connections=100,
|
| 18 |
+
max_keepalive_connections=20,
|
| 19 |
+
),
|
| 20 |
+
timeout=httpx.Timeout(30.0),
|
| 21 |
+
follow_redirects=True,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def close_http_client() -> None:
|
| 26 |
+
"""Call on app shutdown."""
|
| 27 |
+
await http_client.aclose()
|
backend/app/core/lifespan.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend - Application lifespan (startup/shutdown events)."""
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
from fastapi import FastAPI
|
| 9 |
+
|
| 10 |
+
from app.core.logging import get_logger
|
| 11 |
+
|
| 12 |
+
logger = get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
# Background task imports (lazy, at startup time)
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(app: FastAPI):
|
| 18 |
+
"""Application lifespan: startup checks, background tasks, graceful shutdown."""
|
| 19 |
+
vault_pw = os.getenv("WALLET_VAULT_PASSWORD", "").strip()
|
| 20 |
+
if not vault_pw:
|
| 21 |
+
raise RuntimeError(
|
| 22 |
+
"CRITICAL: WALLET_VAULT_PASSWORD environment variable is missing or empty. "
|
| 23 |
+
"The backend will not start without it to prevent silent wallet key loss."
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
app.state.http_client = httpx.AsyncClient(
|
| 27 |
+
limits=httpx.Limits(max_connections=100, max_keepalive_connections=20), timeout=10.0
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
await _verify_indexes(app)
|
| 31 |
+
await _start_background_tasks(app)
|
| 32 |
+
|
| 33 |
+
yield # App runs here
|
| 34 |
+
|
| 35 |
+
await app.state.http_client.aclose()
|
| 36 |
+
logger.info("shutdown_complete")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def _start_background_tasks(app: FastAPI) -> None:
|
| 40 |
+
"""Start all background monitoring / cleanup tasks."""
|
| 41 |
+
tasks = [
|
| 42 |
+
("facilitator_health", "app.routers.facilitator_health", "health_check_loop", "60s"),
|
| 43 |
+
("status_page", "app.routers.status_page", "status_monitor_loop", "30s"),
|
| 44 |
+
("webhook_dispatcher", "app.routers.webhook_dispatcher", "webhook_dispatcher_loop", "5s"),
|
| 45 |
+
("x402_trial_cleanup", "app.routers.x402_enforcement", "trial_cleanup_loop", "hourly"),
|
| 46 |
+
("auto_sweep", "app.wallet_manager_v2", "auto_sweep_loop", ""),
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
for name, module_path, func_name, interval in tasks:
|
| 50 |
+
try:
|
| 51 |
+
mod = __import__(module_path, fromlist=[func_name])
|
| 52 |
+
fn = getattr(mod, func_name)
|
| 53 |
+
if name == "auto_sweep":
|
| 54 |
+
asyncio.create_task(fn())
|
| 55 |
+
else:
|
| 56 |
+
asyncio.create_task(fn())
|
| 57 |
+
logger.info("background_task_started", task=name, interval=interval)
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.warning("background_task_failed", task=name, error=str(e))
|
| 60 |
+
|
| 61 |
+
# Cache warmer (passes app instance)
|
| 62 |
+
try:
|
| 63 |
+
from app.databus.core import cache_warm_loop
|
| 64 |
+
|
| 65 |
+
asyncio.create_task(cache_warm_loop(app))
|
| 66 |
+
logger.info("background_task_started", task="databus_cache_warmer", interval="")
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.warning("background_task_failed", task="databus_cache_warmer", error=str(e))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def _verify_indexes(app: FastAPI) -> None:
|
| 72 |
+
"""Verify and create database indexes on startup."""
|
| 73 |
+
try:
|
| 74 |
+
supabase_url = os.getenv("SUPABASE_URL", "")
|
| 75 |
+
supabase_key = os.getenv("SUPABASE_SERVICE_KEY", "") or os.getenv("SUPABASE_KEY", "")
|
| 76 |
+
if not supabase_url or not supabase_key:
|
| 77 |
+
return
|
| 78 |
+
|
| 79 |
+
indexes = [
|
| 80 |
+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scan_results_created_at ON scan_results(created_at DESC);",
|
| 81 |
+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_scan_results_token_address ON scan_results(token_address);",
|
| 82 |
+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_whale_alerts_created_at ON whale_alerts(created_at DESC);",
|
| 83 |
+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_security_alerts_created_at ON security_alerts(created_at DESC);",
|
| 84 |
+
"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_x402_payments_tx_hash ON x402_payments(tx_hash);",
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
for sql in indexes:
|
| 88 |
+
try:
|
| 89 |
+
res = await app.state.http_client.post(
|
| 90 |
+
f"{supabase_url}/rest/v1/rpc/exec_sql",
|
| 91 |
+
json={"query": sql},
|
| 92 |
+
headers={
|
| 93 |
+
"apikey": supabase_key,
|
| 94 |
+
"Authorization": f"Bearer {supabase_key}",
|
| 95 |
+
"Content-Type": "application/json",
|
| 96 |
+
},
|
| 97 |
+
)
|
| 98 |
+
if res.status_code in [200, 204]:
|
| 99 |
+
logger.info("index_verified", table=sql.split("ON ")[1].split("(")[0].strip())
|
| 100 |
+
else:
|
| 101 |
+
logger.warning("index_skipped", status=res.status_code, sql=sql[:50])
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.warning("index_verify_failed", error=str(e))
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.warning("index_verification_skipped", error=str(e))
|
backend/app/core/llm_cache.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Semantic LLM Cache for DataBus β caches identical + similar prompts. Redis-backed."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
REDIS_URL = os.getenv("REDIS_CACHE_URL", "redis://localhost:6379/1")
|
| 8 |
+
CACHE_TTL = int(os.getenv("LLM_CACHE_TTL", "3600"))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _cache_key(prompt: str, model: str) -> str:
|
| 12 |
+
return f"llm_cache:{hashlib.sha256(f'{model}:{prompt}'.encode()).hexdigest()[:16]}"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def get_cached(prompt: str, model: str) -> dict | None:
|
| 16 |
+
"""Check if prompt+model result is cached."""
|
| 17 |
+
import redis
|
| 18 |
+
|
| 19 |
+
try:
|
| 20 |
+
r = redis.from_url(REDIS_URL, decode_responses=True)
|
| 21 |
+
data = r.get(_cache_key(prompt, model))
|
| 22 |
+
if data:
|
| 23 |
+
return json.loads(data)
|
| 24 |
+
except Exception:
|
| 25 |
+
pass
|
| 26 |
+
return None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def set_cached(prompt: str, model: str, result: dict, ttl: int = CACHE_TTL):
|
| 30 |
+
"""Cache a prompt result."""
|
| 31 |
+
import redis
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
r = redis.from_url(REDIS_URL, decode_responses=True)
|
| 35 |
+
r.setex(_cache_key(prompt, model), ttl, json.dumps(result))
|
| 36 |
+
except Exception:
|
| 37 |
+
pass
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_cache_stats() -> dict:
|
| 41 |
+
"""Get cache statistics."""
|
| 42 |
+
import redis
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
r = redis.from_url(REDIS_URL, decode_responses=True)
|
| 46 |
+
keys = r.keys("llm_cache:*")
|
| 47 |
+
return {"cached_prompts": len(keys)}
|
| 48 |
+
except Exception:
|
| 49 |
+
return {"cached_prompts": 0, "error": "redis unavailable"}
|
backend/app/core/logging.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend - Structured logging with structlog + request ID tracking."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import sys
|
| 5 |
+
|
| 6 |
+
import structlog
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def setup_logging(level: str = "INFO") -> None:
|
| 10 |
+
"""Configure structlog with JSON output for production, console for dev."""
|
| 11 |
+
is_prod = level == "INFO" # JSON in prod, colored console in debug
|
| 12 |
+
|
| 13 |
+
structlog.configure(
|
| 14 |
+
processors=[
|
| 15 |
+
structlog.contextvars.merge_contextvars,
|
| 16 |
+
structlog.stdlib.filter_by_level,
|
| 17 |
+
structlog.stdlib.add_logger_name,
|
| 18 |
+
structlog.stdlib.add_log_level,
|
| 19 |
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
| 20 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 21 |
+
structlog.processors.StackInfoRenderer(),
|
| 22 |
+
structlog.processors.format_exc_info,
|
| 23 |
+
structlog.processors.UnicodeDecoder(),
|
| 24 |
+
structlog.dev.ConsoleRenderer() if not is_prod else structlog.processors.JSONRenderer(),
|
| 25 |
+
],
|
| 26 |
+
context_class=dict,
|
| 27 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 28 |
+
wrapper_class=structlog.stdlib.BoundLogger,
|
| 29 |
+
cache_logger_on_first_use=True,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
# Set root logger level
|
| 33 |
+
logging.basicConfig(format="%(message)s", stream=sys.stdout, level=getattr(logging, level))
|
| 34 |
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
| 35 |
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
| 39 |
+
"""Get a structlog logger for a module."""
|
| 40 |
+
return structlog.get_logger(name)
|
backend/app/core/metrics.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prometheus metrics endpoint for RMI Backend.
|
| 2 |
+
|
| 3 |
+
Exposes /metrics with: request latency, error rates, DataBus cache hits,
|
| 4 |
+
provider health. Scraped by Prometheus on VPS at :9090.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, Request, Response
|
| 10 |
+
from prometheus_client import REGISTRY, Counter, Gauge, Histogram, generate_latest
|
| 11 |
+
|
| 12 |
+
# ββ Metrics definitions ββ
|
| 13 |
+
REQUEST_COUNT = Counter(
|
| 14 |
+
"rmi_http_requests_total",
|
| 15 |
+
"Total HTTP requests",
|
| 16 |
+
["method", "endpoint", "status"],
|
| 17 |
+
)
|
| 18 |
+
REQUEST_LATENCY = Histogram(
|
| 19 |
+
"rmi_http_request_duration_seconds",
|
| 20 |
+
"HTTP request latency",
|
| 21 |
+
["method", "endpoint"],
|
| 22 |
+
)
|
| 23 |
+
ERROR_COUNT = Counter(
|
| 24 |
+
"rmi_http_errors_total",
|
| 25 |
+
"Total HTTP errors",
|
| 26 |
+
["method", "endpoint", "error_type"],
|
| 27 |
+
)
|
| 28 |
+
DATABUS_CACHE_HITS = Counter(
|
| 29 |
+
"rmi_databus_cache_hits_total",
|
| 30 |
+
"DataBus cache hits",
|
| 31 |
+
["data_type", "cache_level"],
|
| 32 |
+
)
|
| 33 |
+
DATABUS_CACHE_MISSES = Counter(
|
| 34 |
+
"rmi_databus_cache_misses_total",
|
| 35 |
+
"DataBus cache misses",
|
| 36 |
+
["data_type"],
|
| 37 |
+
)
|
| 38 |
+
ACTIVE_REQUESTS = Gauge(
|
| 39 |
+
"rmi_http_requests_active",
|
| 40 |
+
"Currently active requests",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def setup_metrics(app: FastAPI) -> None:
|
| 45 |
+
"""Add /metrics endpoint and request-tracking middleware."""
|
| 46 |
+
|
| 47 |
+
@app.get("/metrics", include_in_schema=False)
|
| 48 |
+
async def metrics():
|
| 49 |
+
return Response(content=generate_latest(REGISTRY), media_type="text/plain")
|
| 50 |
+
|
| 51 |
+
@app.middleware("http")
|
| 52 |
+
async def metrics_middleware(request: Request, call_next):
|
| 53 |
+
ACTIVE_REQUESTS.inc()
|
| 54 |
+
start = time.perf_counter()
|
| 55 |
+
response = await call_next(request)
|
| 56 |
+
elapsed = time.perf_counter() - start
|
| 57 |
+
ACTIVE_REQUESTS.dec()
|
| 58 |
+
|
| 59 |
+
endpoint = request.url.path
|
| 60 |
+
method = request.method
|
| 61 |
+
status = str(response.status_code)
|
| 62 |
+
|
| 63 |
+
REQUEST_COUNT.labels(method=method, endpoint=endpoint, status=status).inc()
|
| 64 |
+
REQUEST_LATENCY.labels(method=method, endpoint=endpoint).observe(elapsed)
|
| 65 |
+
|
| 66 |
+
if response.status_code >= 400:
|
| 67 |
+
ERROR_COUNT.labels(method=method, endpoint=endpoint, error_type=str(response.status_code)).inc()
|
| 68 |
+
|
| 69 |
+
return response
|
backend/app/core/middleware.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend β Core Middleware."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from fastapi import Request
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
|
| 10 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 11 |
+
# Rate-limit config
|
| 12 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
CACHEABLE_TOOLS = {"token_price", "token_metadata", "wallet_tokens", "entity_intel"}
|
| 14 |
+
|
| 15 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 16 |
+
# Payload size limit
|
| 17 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
MAX_PAYLOAD_SIZE = 1_048_576 # 1MB for standard JSON APIs
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def cache_middleware(request: Request, call_next):
|
| 22 |
+
"""Check Redis cache before executing tool. Store result after."""
|
| 23 |
+
path = request.url.path
|
| 24 |
+
if not path.startswith("/api/v1/x402-tools/"):
|
| 25 |
+
return await call_next(request)
|
| 26 |
+
if request.method != "POST":
|
| 27 |
+
return await call_next(request)
|
| 28 |
+
|
| 29 |
+
tool = path.rstrip("/").split("/")[-1]
|
| 30 |
+
if tool not in CACHEABLE_TOOLS:
|
| 31 |
+
return await call_next(request)
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
body = await request.body()
|
| 35 |
+
params = json.loads(body) if body else {}
|
| 36 |
+
except Exception:
|
| 37 |
+
return await call_next(request)
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from app.routers.x402_advanced_tools import get_cached, set_cached
|
| 41 |
+
|
| 42 |
+
cached = get_cached(tool, params)
|
| 43 |
+
if cached:
|
| 44 |
+
return JSONResponse(content=cached, headers={"X-Cache": "HIT", "X-Cache-TTL": "60"})
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
response = await call_next(request)
|
| 49 |
+
if response.status_code == 200:
|
| 50 |
+
try:
|
| 51 |
+
resp_body = b""
|
| 52 |
+
async for chunk in response.body_iterator:
|
| 53 |
+
resp_body += chunk
|
| 54 |
+
result = json.loads(resp_body)
|
| 55 |
+
set_cached(tool, params, result)
|
| 56 |
+
return JSONResponse(
|
| 57 |
+
content=result,
|
| 58 |
+
status_code=response.status_code,
|
| 59 |
+
headers={**dict(response.headers), "X-Cache": "MISS"},
|
| 60 |
+
)
|
| 61 |
+
except Exception:
|
| 62 |
+
pass
|
| 63 |
+
return response
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def emergency_lockdown_middleware(request: Request, call_next):
|
| 67 |
+
"""Check for emergency lockdown status. Block non-admin routes if active."""
|
| 68 |
+
if request.url.path in ["/health", "/api/v1/admin/emergency-lockdown", "/api/v1/admin/emergency-status"]:
|
| 69 |
+
return await call_next(request)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
import redis.asyncio as redis_lib
|
| 73 |
+
|
| 74 |
+
r = redis_lib.Redis(
|
| 75 |
+
host=os.getenv("REDIS_HOST", "localhost"),
|
| 76 |
+
port=int(os.getenv("REDIS_PORT", "6379")),
|
| 77 |
+
password=os.getenv("REDIS_PASSWORD", ""),
|
| 78 |
+
decode_responses=True,
|
| 79 |
+
)
|
| 80 |
+
is_locked = await r.exists("rmi:emergency_lockdown")
|
| 81 |
+
if is_locked:
|
| 82 |
+
auth_header = request.headers.get("Authorization", "")
|
| 83 |
+
session_token = request.headers.get("X-Admin-Session", "")
|
| 84 |
+
if not auth_header and not session_token:
|
| 85 |
+
return JSONResponse(
|
| 86 |
+
status_code=503,
|
| 87 |
+
content={"error": "Service Unavailable", "detail": "System is in emergency lockdown."},
|
| 88 |
+
)
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
+
return await call_next(request)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
async def hsts_middleware(request: Request, call_next):
|
| 95 |
+
"""Force HTTPS and prevent protocol downgrade attacks."""
|
| 96 |
+
response = await call_next(request)
|
| 97 |
+
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
|
| 98 |
+
return response
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def request_id_middleware(request: Request, call_next):
|
| 102 |
+
"""Generate unique request ID for log correlation."""
|
| 103 |
+
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
| 104 |
+
response = await call_next(request)
|
| 105 |
+
response.headers["X-Request-ID"] = request_id
|
| 106 |
+
return response
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def payload_size_limit_middleware(request: Request, call_next):
|
| 110 |
+
"""Enforce 1MB payload limit on non-upload routes."""
|
| 111 |
+
content_length = request.headers.get("content-length")
|
| 112 |
+
if content_length and int(content_length) > MAX_PAYLOAD_SIZE:
|
| 113 |
+
if not request.url.path.startswith("/api/v1/admin/backend/upload/"):
|
| 114 |
+
return JSONResponse(
|
| 115 |
+
status_code=413,
|
| 116 |
+
content={"error": "Payload Too Large", "detail": "Maximum payload size is 1MB"},
|
| 117 |
+
)
|
| 118 |
+
return await call_next(request)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
async def secure_cookie_middleware(request: Request, call_next):
|
| 122 |
+
"""Enforce secure cookie flags for admin sessions."""
|
| 123 |
+
response = await call_next(request)
|
| 124 |
+
if "set-cookie" in response.headers:
|
| 125 |
+
cookie_val = response.headers["set-cookie"]
|
| 126 |
+
if "HttpOnly" not in cookie_val:
|
| 127 |
+
cookie_val += "; HttpOnly"
|
| 128 |
+
if "Secure" not in cookie_val:
|
| 129 |
+
cookie_val += "; Secure"
|
| 130 |
+
if "SameSite=Strict" not in cookie_val and "SameSite=Lax" not in cookie_val:
|
| 131 |
+
cookie_val += "; SameSite=Strict"
|
| 132 |
+
response.headers["set-cookie"] = cookie_val
|
| 133 |
+
return response
|
backend/app/core/mistral_provider.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Mistral AI provider for DataBus β Free tier: 1B tokens/month, 1 req/sec.
|
| 2 |
+
Credit-conserving: uses Small 4 for bulk, Medium 3.5 only when needed."""
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import httpx
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
MISTRAL_KEY = os.getenv("MISTRAL_API_KEY", "")
|
| 12 |
+
MISTRAL_BASE = "https://api.mistral.ai/v1"
|
| 13 |
+
|
| 14 |
+
# Model selection by task β free tier optimized
|
| 15 |
+
MODELS = {
|
| 16 |
+
"fast": "mistral-small-latest", # Small 4 β 90% of calls, ~$0.1/1M tokens
|
| 17 |
+
"smart": "mistral-medium-latest", # Medium 3.5 β complex analysis only
|
| 18 |
+
"embed": "mistral-embed", # Embeddings β state of art
|
| 19 |
+
"code": "mistral-small-latest", # Small 4 handles code well
|
| 20 |
+
"moderate": "mistral-moderation-latest", # Content moderation
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
# β οΈ Deprecated β do NOT use
|
| 24 |
+
# mistral-small-2506 β deprecated, retiring July 2026
|
| 25 |
+
# mistral-medium-2508 β deprecated, retiring Aug 2026
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def mistral_chat(
|
| 29 |
+
prompt: str, model: str = None, system: str = None, max_tokens: int = 512, temperature: float = 0.7
|
| 30 |
+
) -> dict | None:
|
| 31 |
+
"""Chat completion via Mistral. Conserves free credits by using fast models."""
|
| 32 |
+
if not MISTRAL_KEY:
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
model = model or MODELS["fast"]
|
| 36 |
+
messages = []
|
| 37 |
+
if system:
|
| 38 |
+
messages.append({"role": "system", "content": system})
|
| 39 |
+
messages.append({"role": "user", "content": prompt})
|
| 40 |
+
|
| 41 |
+
try:
|
| 42 |
+
async with httpx.AsyncClient(timeout=30) as c:
|
| 43 |
+
r = await c.post(
|
| 44 |
+
f"{MISTRAL_BASE}/chat/completions",
|
| 45 |
+
json={
|
| 46 |
+
"model": model,
|
| 47 |
+
"messages": messages,
|
| 48 |
+
"max_tokens": max_tokens,
|
| 49 |
+
"temperature": temperature,
|
| 50 |
+
},
|
| 51 |
+
headers={"Authorization": f"Bearer {MISTRAL_KEY}"},
|
| 52 |
+
)
|
| 53 |
+
if r.status_code == 200:
|
| 54 |
+
d = r.json()
|
| 55 |
+
return {
|
| 56 |
+
"response": d["choices"][0]["message"]["content"],
|
| 57 |
+
"model": model,
|
| 58 |
+
"tokens": d.get("usage", {}).get("total_tokens", 0),
|
| 59 |
+
"provider": "mistral",
|
| 60 |
+
}
|
| 61 |
+
elif r.status_code == 429:
|
| 62 |
+
logger.warning("Mistral rate limit hit β waiting...")
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.warning(f"Mistral chat failed: {e}")
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def mistral_embed(text: str) -> list | None:
|
| 69 |
+
"""Generate embeddings via Mistral Embed β state of art."""
|
| 70 |
+
if not MISTRAL_KEY:
|
| 71 |
+
return None
|
| 72 |
+
try:
|
| 73 |
+
async with httpx.AsyncClient(timeout=15) as c:
|
| 74 |
+
r = await c.post(
|
| 75 |
+
f"{MISTRAL_BASE}/embeddings",
|
| 76 |
+
json={"model": MODELS["embed"], "input": [text]},
|
| 77 |
+
headers={"Authorization": f"Bearer {MISTRAL_KEY}"},
|
| 78 |
+
)
|
| 79 |
+
if r.status_code == 200:
|
| 80 |
+
return r.json()["data"][0]["embedding"]
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.warning(f"Mistral embed failed: {e}")
|
| 83 |
+
return None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
async def mistral_moderate(text: str) -> dict | None:
|
| 87 |
+
"""Content moderation β jailbreak, toxicity, PII detection."""
|
| 88 |
+
if not MISTRAL_KEY:
|
| 89 |
+
return None
|
| 90 |
+
try:
|
| 91 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 92 |
+
r = await c.post(
|
| 93 |
+
f"{MISTRAL_BASE}/chat/completions",
|
| 94 |
+
json={
|
| 95 |
+
"model": MODELS["moderate"],
|
| 96 |
+
"messages": [{"role": "user", "content": text}],
|
| 97 |
+
"max_tokens": 10,
|
| 98 |
+
"temperature": 0,
|
| 99 |
+
},
|
| 100 |
+
headers={"Authorization": f"Bearer {MISTRAL_KEY}"},
|
| 101 |
+
)
|
| 102 |
+
if r.status_code == 200:
|
| 103 |
+
return {"flagged": False, "provider": "mistral"}
|
| 104 |
+
except Exception:
|
| 105 |
+
pass
|
| 106 |
+
return None
|
backend/app/core/model_eval.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""#8 β Model Evaluation Harness. Benchmarks models on Real-CATS scam data.
|
| 3 |
+
Runs lm-eval locally or via Ollama. Picks the best model per task."""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
|
| 14 |
+
OLLAMA = os.getenv("OLLAMA_HOST", "http://localhost:11434")
|
| 15 |
+
REAL_CATS_PATH = Path(os.getenv("REAL_CATS_PATH", str(Path.home() / "rmi/backend/data/real_cats.json")))
|
| 16 |
+
|
| 17 |
+
# Test prompts for scam classification
|
| 18 |
+
BENCHMARK_TASKS = {
|
| 19 |
+
"scam_detection": {
|
| 20 |
+
"prompts": [
|
| 21 |
+
{
|
| 22 |
+
"input": "Token has mint authority enabled, liquidity is 0.5 SOL unlocked, deployer created 50 tokens before. Is this a scam?",
|
| 23 |
+
"expected": "yes",
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"input": "Token has renounced mint, liquidity locked for 1 year, verified contract, audited by CertiK. Is this a scam?",
|
| 27 |
+
"expected": "no",
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"input": "Token has honeypot detection enabled, 99% sell tax, unverified contract, anonymous team. Is this a scam?",
|
| 31 |
+
"expected": "yes",
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"input": "Token listed on Binance, $50M market cap, 100K holders, 2 years old. Is this a scam?",
|
| 35 |
+
"expected": "no",
|
| 36 |
+
},
|
| 37 |
+
],
|
| 38 |
+
"metric": "accuracy",
|
| 39 |
+
},
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def evaluate_model(model: str, task_name: str) -> dict[str, Any]:
|
| 44 |
+
"""Evaluate a model on a benchmark task."""
|
| 45 |
+
task = BENCHMARK_TASKS.get(task_name)
|
| 46 |
+
if not task:
|
| 47 |
+
return {"error": f"Unknown task: {task_name}"}
|
| 48 |
+
|
| 49 |
+
correct = 0
|
| 50 |
+
total = 0
|
| 51 |
+
total_time = 0.0
|
| 52 |
+
results = []
|
| 53 |
+
|
| 54 |
+
async with httpx.AsyncClient(timeout=60) as c:
|
| 55 |
+
for item in task["prompts"]:
|
| 56 |
+
start = time.perf_counter()
|
| 57 |
+
try:
|
| 58 |
+
r = await c.post(
|
| 59 |
+
f"{OLLAMA}/api/generate",
|
| 60 |
+
json={
|
| 61 |
+
"model": model,
|
| 62 |
+
"prompt": f"Answer only YES or NO. {item['input']}",
|
| 63 |
+
"stream": False,
|
| 64 |
+
"options": {"num_predict": 5, "temperature": 0.1},
|
| 65 |
+
},
|
| 66 |
+
)
|
| 67 |
+
elapsed = time.perf_counter() - start
|
| 68 |
+
total_time += elapsed
|
| 69 |
+
|
| 70 |
+
response = r.json().get("response", "").strip().upper()
|
| 71 |
+
is_correct = item["expected"].upper() in response
|
| 72 |
+
if is_correct:
|
| 73 |
+
correct += 1
|
| 74 |
+
total += 1
|
| 75 |
+
results.append(
|
| 76 |
+
{
|
| 77 |
+
"input": item["input"][:80],
|
| 78 |
+
"expected": item["expected"],
|
| 79 |
+
"got": response[:20],
|
| 80 |
+
"correct": is_correct,
|
| 81 |
+
"time_ms": round(elapsed * 1000),
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
except Exception as e:
|
| 85 |
+
results.append({"input": item["input"][:80], "error": str(e)})
|
| 86 |
+
total += 1
|
| 87 |
+
|
| 88 |
+
accuracy = (correct / total * 100) if total > 0 else 0
|
| 89 |
+
return {
|
| 90 |
+
"model": model,
|
| 91 |
+
"task": task_name,
|
| 92 |
+
"accuracy": round(accuracy, 1),
|
| 93 |
+
"correct": correct,
|
| 94 |
+
"total": total,
|
| 95 |
+
"avg_time_ms": round((total_time / total) * 1000) if total > 0 else 0,
|
| 96 |
+
"results": results,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def compare_models(models: list[str], task: str = "scam_detection"):
|
| 101 |
+
"""Compare multiple models on a benchmark task."""
|
| 102 |
+
scores = []
|
| 103 |
+
for model in models:
|
| 104 |
+
result = await evaluate_model(model, task)
|
| 105 |
+
scores.append(result)
|
| 106 |
+
|
| 107 |
+
scores.sort(key=lambda s: s["accuracy"], reverse=True)
|
| 108 |
+
return {
|
| 109 |
+
"task": task,
|
| 110 |
+
"models_compared": len(scores),
|
| 111 |
+
"leaderboard": [
|
| 112 |
+
{"model": s["model"], "accuracy": s["accuracy"], "avg_time_ms": s["avg_time_ms"]} for s in scores
|
| 113 |
+
],
|
| 114 |
+
"best_model": scores[0]["model"] if scores else None,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
if __name__ == "__main__":
|
| 119 |
+
|
| 120 |
+
async def main():
|
| 121 |
+
print("Model Evaluation Harness")
|
| 122 |
+
print("=" * 40)
|
| 123 |
+
models = ["qwen2.5-coder:7b", "mistral:7b"]
|
| 124 |
+
results = await compare_models(models)
|
| 125 |
+
print(json.dumps(results["leaderboard"], indent=2))
|
| 126 |
+
print(f"\nBest model for scam detection: {results['best_model']}")
|
| 127 |
+
|
| 128 |
+
asyncio.run(main())
|
backend/app/core/model_router.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Intelligent Model Router β auto-routes to best provider by task type, cost, latency.
|
| 2 |
+
Priority: real-time β Cerebras (9ms), cheap β Ollama ($0), complex β DeepSeek, bulk β Mistral."""
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from enum import Enum
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TaskType(Enum):
|
| 9 |
+
FAST = "fast" # < 100ms needed β Cerebras, Groq
|
| 10 |
+
CHEAP = "cheap" # cost-sensitive β Ollama, Mistral free tier
|
| 11 |
+
COMPLEX = "complex" # reasoning needed β DeepSeek V4 Pro
|
| 12 |
+
BULK = "bulk" # high volume β Mistral Small 4
|
| 13 |
+
VISION = "vision" # image understanding β Gemini
|
| 14 |
+
EMBED = "embed" # embeddings β Mistral Embed
|
| 15 |
+
CODE = "code" # code generation β DeepSeek, qwen2.5-coder
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
ROUTING_TABLE = {
|
| 19 |
+
TaskType.FAST: [
|
| 20 |
+
("gpt-oss-120b", "cerebras", 0.009, 0.0), # 9ms, free
|
| 21 |
+
("llama-3.3-70b", "groq", 0.1, 0.0), # 100ms, free
|
| 22 |
+
],
|
| 23 |
+
TaskType.CHEAP: [
|
| 24 |
+
("qwen2.5-coder:7b", "ollama", 2.0, 0.0), # 2s, free
|
| 25 |
+
("mistral-small-latest", "mistral", 0.2, 0.0), # 200ms, free
|
| 26 |
+
],
|
| 27 |
+
TaskType.COMPLEX: [
|
| 28 |
+
("deepseek-v4-pro", "deepseek", 0.5, 0.55), # 500ms, $0.55/1M input
|
| 29 |
+
("mistral-medium-latest", "mistral", 0.3, 0.0), # 300ms, free
|
| 30 |
+
],
|
| 31 |
+
TaskType.BULK: [
|
| 32 |
+
("mistral-small-latest", "mistral", 0.2, 0.0), # 200ms, free, 2M TPM
|
| 33 |
+
("deepseek-v4-flash", "deepseek", 0.3, 0.14), # 300ms, cheap
|
| 34 |
+
],
|
| 35 |
+
TaskType.VISION: [
|
| 36 |
+
("gemini-2.5-flash", "gemini", 0.3, 0.0), # 300ms, free tier
|
| 37 |
+
],
|
| 38 |
+
TaskType.EMBED: [
|
| 39 |
+
("mistral-embed", "mistral", 0.1, 0.0), # 100ms, 20M TPM free
|
| 40 |
+
("bge-m3", "ollama", 2.0, 0.0), # 2s, local
|
| 41 |
+
],
|
| 42 |
+
TaskType.CODE: [
|
| 43 |
+
("deepseek-v4-flash", "deepseek", 0.3, 0.14), # 300ms, cheap
|
| 44 |
+
("qwen2.5-coder:7b", "ollama", 2.0, 0.0), # 2s, free
|
| 45 |
+
("mistral-small-latest", "mistral", 0.2, 0.0), # 200ms, free
|
| 46 |
+
],
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class RoutingDecision:
|
| 52 |
+
model: str
|
| 53 |
+
provider: str
|
| 54 |
+
estimated_latency_ms: float
|
| 55 |
+
cost_per_1m_input: float
|
| 56 |
+
fallback_model: str | None = None
|
| 57 |
+
fallback_provider: str | None = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def route_task(task_type: TaskType, prefer: str = "fast") -> RoutingDecision:
|
| 61 |
+
"""Route a task to the best model. Falls back to next on failure."""
|
| 62 |
+
candidates = ROUTING_TABLE.get(task_type, ROUTING_TABLE[TaskType.FAST])
|
| 63 |
+
|
| 64 |
+
if prefer == "cheap":
|
| 65 |
+
candidates = sorted(candidates, key=lambda c: c[3]) # sort by cost
|
| 66 |
+
elif prefer == "fast":
|
| 67 |
+
candidates = sorted(candidates, key=lambda c: c[2]) # sort by latency
|
| 68 |
+
|
| 69 |
+
primary = candidates[0]
|
| 70 |
+
fallback = candidates[1] if len(candidates) > 1 else None
|
| 71 |
+
|
| 72 |
+
return RoutingDecision(
|
| 73 |
+
model=primary[0],
|
| 74 |
+
provider=primary[1],
|
| 75 |
+
estimated_latency_ms=primary[2] * 1000,
|
| 76 |
+
cost_per_1m_input=primary[3],
|
| 77 |
+
fallback_model=fallback[0] if fallback else None,
|
| 78 |
+
fallback_provider=fallback[1] if fallback else None,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
async def smart_route(prompt: str, task_type: str = "fast", prefer: str = "fast", **kwargs):
|
| 83 |
+
"""Auto-route a prompt to the best model. Returns response or falls back."""
|
| 84 |
+
decision = route_task(TaskType(task_type), prefer)
|
| 85 |
+
|
| 86 |
+
# Try primary
|
| 87 |
+
result = await _call_provider(decision.provider, decision.model, prompt, **kwargs)
|
| 88 |
+
if result:
|
| 89 |
+
return {**result, "routing": vars(decision), "fallback_used": False}
|
| 90 |
+
|
| 91 |
+
# Try fallback
|
| 92 |
+
if decision.fallback_model:
|
| 93 |
+
result = await _call_provider(decision.fallback_provider, decision.fallback_model, prompt, **kwargs)
|
| 94 |
+
if result:
|
| 95 |
+
return {**result, "routing": vars(decision), "fallback_used": True}
|
| 96 |
+
|
| 97 |
+
return {"error": "All providers failed", "routing": vars(decision)}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
async def _call_provider(provider: str, model: str, prompt: str, **kwargs):
|
| 101 |
+
"""Call a specific provider. Returns dict or None."""
|
| 102 |
+
try:
|
| 103 |
+
if provider == "cerebras":
|
| 104 |
+
from app.core.cerebras_provider import cerebras_chat
|
| 105 |
+
|
| 106 |
+
return await cerebras_chat(prompt, **kwargs)
|
| 107 |
+
elif provider == "mistral":
|
| 108 |
+
from app.core.mistral_provider import mistral_chat
|
| 109 |
+
|
| 110 |
+
return await mistral_chat(prompt, model=model, **kwargs)
|
| 111 |
+
elif provider == "ollama":
|
| 112 |
+
import httpx
|
| 113 |
+
|
| 114 |
+
async with httpx.AsyncClient(timeout=60) as c:
|
| 115 |
+
r = await c.post(
|
| 116 |
+
"http://localhost:11434/api/generate", json={"model": model, "prompt": prompt, "stream": False}
|
| 117 |
+
)
|
| 118 |
+
if r.status_code == 200:
|
| 119 |
+
return {"response": r.json()["response"], "model": model, "provider": "ollama"}
|
| 120 |
+
# DeepSeek, Groq, Gemini handled via existing DataBus providers
|
| 121 |
+
except Exception:
|
| 122 |
+
pass
|
| 123 |
+
return None
|
backend/app/core/prompt_registry.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""#7 β Prompt Registry. Git-versioned prompts with hot-reload support.
|
| 2 |
+
Store prompts in prompts/*.yaml. Load at startup, reload via API."""
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import yaml
|
| 9 |
+
from fastapi import APIRouter
|
| 10 |
+
|
| 11 |
+
PROMPTS_DIR = Path(os.getenv("PROMPTS_DIR", str(Path(__file__).parent.parent.parent / "prompts")))
|
| 12 |
+
router = APIRouter(prefix="/api/v1/prompts", tags=["prompts"])
|
| 13 |
+
|
| 14 |
+
_registry: dict[str, dict] = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_all_prompts() -> dict[str, dict]:
|
| 18 |
+
"""Load all prompts from prompts/ directory."""
|
| 19 |
+
global _registry
|
| 20 |
+
_registry = {}
|
| 21 |
+
if not PROMPTS_DIR.exists():
|
| 22 |
+
PROMPTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 23 |
+
|
| 24 |
+
for f in PROMPTS_DIR.glob("*.yaml"):
|
| 25 |
+
try:
|
| 26 |
+
with open(f) as fh:
|
| 27 |
+
data = yaml.safe_load(fh)
|
| 28 |
+
name = f.stem
|
| 29 |
+
_registry[name] = {
|
| 30 |
+
"name": name,
|
| 31 |
+
"version": data.get("version", "1.0"),
|
| 32 |
+
"system": data.get("system", ""),
|
| 33 |
+
"template": data.get("template", ""),
|
| 34 |
+
"model": data.get("model", "deepseek-v4-flash"),
|
| 35 |
+
"temperature": data.get("temperature", 0.7),
|
| 36 |
+
"max_tokens": data.get("max_tokens", 1024),
|
| 37 |
+
"file": str(f),
|
| 38 |
+
}
|
| 39 |
+
except Exception:
|
| 40 |
+
pass
|
| 41 |
+
return _registry
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_prompt(name: str) -> dict[str, Any]:
|
| 45 |
+
"""Get a prompt by name."""
|
| 46 |
+
if name not in _registry:
|
| 47 |
+
load_all_prompts()
|
| 48 |
+
return _registry.get(name, {})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def render_prompt(name: str, **kwargs) -> str:
|
| 52 |
+
"""Render a prompt template with variables."""
|
| 53 |
+
prompt = get_prompt(name)
|
| 54 |
+
template = prompt.get("template", "")
|
| 55 |
+
system = prompt.get("system", "")
|
| 56 |
+
try:
|
| 57 |
+
rendered = template.format(**kwargs)
|
| 58 |
+
except KeyError:
|
| 59 |
+
rendered = template
|
| 60 |
+
if system:
|
| 61 |
+
return f"{system}\n\n{rendered}"
|
| 62 |
+
return rendered
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/")
|
| 66 |
+
async def list_prompts():
|
| 67 |
+
return {"prompts": list(_registry.values()), "count": len(_registry)}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/{name}")
|
| 71 |
+
async def get_prompt_info(name: str):
|
| 72 |
+
p = get_prompt(name)
|
| 73 |
+
if not p:
|
| 74 |
+
return {"error": "not found"}
|
| 75 |
+
return p
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@router.post("/reload")
|
| 79 |
+
async def reload_prompts():
|
| 80 |
+
load_all_prompts()
|
| 81 |
+
return {"reloaded": len(_registry)}
|
backend/app/core/rate_limiter.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""3-Tier Rate Limiter β Free/Pro/Enterprise with crypto paywall.
|
| 2 |
+
Realistic limits for 31GB RAM, 12 vCPU, 42 containers. Competitive with market.
|
| 3 |
+
|
| 4 |
+
FREE: 100 req/day, 10 req/min, 10 SENTINEL scans/day
|
| 5 |
+
PRO: $19.99/mo (SOL/ETH/USDC), 10K req/day, 60 req/min, 100 scans/day
|
| 6 |
+
ENTERPRISE: $499/mo, unlimited, white-label, dedicated support"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import time
|
| 10 |
+
from enum import Enum
|
| 11 |
+
|
| 12 |
+
from fastapi import APIRouter, Header
|
| 13 |
+
from pydantic import BaseModel
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/api/v1/rate-limits", tags=["rate-limits"])
|
| 16 |
+
|
| 17 |
+
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/3")
|
| 18 |
+
X402_VERIFY = os.getenv("X402_VERIFY_URL", "http://localhost:8000/api/v1/x402/verify")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Tier(str, Enum):
|
| 22 |
+
FREE = "free"
|
| 23 |
+
PRO = "pro"
|
| 24 |
+
ENTERPRISE = "enterprise"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# Realistic limits for our infrastructure
|
| 28 |
+
TIER_LIMITS = {
|
| 29 |
+
Tier.FREE: {
|
| 30 |
+
"requests_per_day": 100,
|
| 31 |
+
"requests_per_minute": 10,
|
| 32 |
+
"sentinel_scans_per_day": 10,
|
| 33 |
+
"ai_chats_per_day": 20,
|
| 34 |
+
"databus_queries_per_day": 50,
|
| 35 |
+
"concurrent_requests": 2,
|
| 36 |
+
"price_monthly_usd": 0,
|
| 37 |
+
"features": ["basic_scan", "market_data", "fear_greed", "trending"],
|
| 38 |
+
},
|
| 39 |
+
Tier.PRO: {
|
| 40 |
+
"requests_per_day": 10000,
|
| 41 |
+
"requests_per_minute": 60,
|
| 42 |
+
"sentinel_scans_per_day": 100,
|
| 43 |
+
"ai_chats_per_day": 500,
|
| 44 |
+
"databus_queries_per_day": 5000,
|
| 45 |
+
"concurrent_requests": 10,
|
| 46 |
+
"price_monthly_usd": 19.99,
|
| 47 |
+
"features": [
|
| 48 |
+
"deep_scan",
|
| 49 |
+
"signals",
|
| 50 |
+
"whale_alerts",
|
| 51 |
+
"api_access",
|
| 52 |
+
"ai_chat",
|
| 53 |
+
"sentiment",
|
| 54 |
+
"arbitrage",
|
| 55 |
+
"mev_advisory",
|
| 56 |
+
],
|
| 57 |
+
},
|
| 58 |
+
Tier.ENTERPRISE: {
|
| 59 |
+
"requests_per_day": 999999,
|
| 60 |
+
"requests_per_minute": 300,
|
| 61 |
+
"sentinel_scans_per_day": 99999,
|
| 62 |
+
"ai_chats_per_day": 99999,
|
| 63 |
+
"databus_queries_per_day": 99999,
|
| 64 |
+
"concurrent_requests": 50,
|
| 65 |
+
"price_monthly_usd": 499,
|
| 66 |
+
"features": ["all", "custom_models", "white_label", "dedicated_support", "sla"],
|
| 67 |
+
},
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# Payment options
|
| 71 |
+
PAYMENT_OPTIONS = {
|
| 72 |
+
"solana": {"chain": "solana", "token": "USDC", "address": "PAY_WALLET_ADDRESS_HERE"},
|
| 73 |
+
"ethereum": {"chain": "ethereum", "token": "USDC", "address": "0x_PAY_WALLET_ADDRESS_HERE"},
|
| 74 |
+
"x402": {"chain": "any", "token": "any", "note": "Pay-per-call via x402 protocol"},
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
# In-memory user store (Redis in prod)
|
| 78 |
+
_users: dict[str, dict] = {}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class UpgradeRequest(BaseModel):
|
| 82 |
+
user_id: str
|
| 83 |
+
tier: Tier
|
| 84 |
+
tx_signature: str = "" # blockchain tx for verification
|
| 85 |
+
chain: str = "solana"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _get_redis():
|
| 89 |
+
import redis
|
| 90 |
+
|
| 91 |
+
return redis.from_url(REDIS_URL, decode_responses=True)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def get_user_tier(user_id: str) -> Tier:
|
| 95 |
+
"""Get user's current tier. Defaults to FREE."""
|
| 96 |
+
if user_id in _users:
|
| 97 |
+
return Tier(_users[user_id].get("tier", "free"))
|
| 98 |
+
# Check Redis
|
| 99 |
+
try:
|
| 100 |
+
r = _get_redis()
|
| 101 |
+
tier = r.get(f"rmi:user_tier:{user_id}")
|
| 102 |
+
if tier:
|
| 103 |
+
return Tier(tier)
|
| 104 |
+
except Exception:
|
| 105 |
+
pass
|
| 106 |
+
return Tier.FREE
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def check_rate_limit(user_id: str, endpoint: str) -> bool:
|
| 110 |
+
"""Check if user has remaining quota. Returns True if allowed."""
|
| 111 |
+
tier = get_user_tier(user_id)
|
| 112 |
+
limits = TIER_LIMITS[tier]
|
| 113 |
+
|
| 114 |
+
try:
|
| 115 |
+
r = _get_redis()
|
| 116 |
+
today = time.strftime("%Y-%m-%d")
|
| 117 |
+
|
| 118 |
+
# Per-minute limit
|
| 119 |
+
minute_key = f"rmi:ratelimit:{user_id}:{today}:minute"
|
| 120 |
+
minute_count = int(r.get(minute_key) or 0)
|
| 121 |
+
if minute_count >= limits["requests_per_minute"]:
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
# Per-day limit
|
| 125 |
+
day_key = f"rmi:ratelimit:{user_id}:{today}:total"
|
| 126 |
+
day_count = int(r.get(day_key) or 0)
|
| 127 |
+
if day_count >= limits["requests_per_day"]:
|
| 128 |
+
return False
|
| 129 |
+
|
| 130 |
+
# Increment counters
|
| 131 |
+
pipe = r.pipeline()
|
| 132 |
+
pipe.incr(minute_key)
|
| 133 |
+
pipe.expire(minute_key, 60)
|
| 134 |
+
pipe.incr(day_key)
|
| 135 |
+
pipe.expire(day_key, 86400)
|
| 136 |
+
pipe.execute()
|
| 137 |
+
return True
|
| 138 |
+
except Exception:
|
| 139 |
+
return True # Fail open if Redis down
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@router.get("/tiers")
|
| 143 |
+
async def get_tiers():
|
| 144 |
+
"""Get all pricing tiers and features."""
|
| 145 |
+
return {
|
| 146 |
+
"tiers": {
|
| 147 |
+
t.value: {
|
| 148 |
+
"price_monthly_usd": TIER_LIMITS[t]["price_monthly_usd"],
|
| 149 |
+
"requests_per_day": TIER_LIMITS[t]["requests_per_day"],
|
| 150 |
+
"features": TIER_LIMITS[t]["features"],
|
| 151 |
+
}
|
| 152 |
+
for t in Tier
|
| 153 |
+
},
|
| 154 |
+
"payment_methods": list(PAYMENT_OPTIONS.keys()),
|
| 155 |
+
"note": "Crypto payments only. Solana USDC, Ethereum USDC, or x402 pay-per-call.",
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@router.get("/my-tier")
|
| 160 |
+
async def my_tier(x_api_key: str = Header(None)):
|
| 161 |
+
"""Get current user's tier and usage."""
|
| 162 |
+
user_id = x_api_key or "anonymous"
|
| 163 |
+
tier = get_user_tier(user_id)
|
| 164 |
+
limits = TIER_LIMITS[tier]
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
r = _get_redis()
|
| 168 |
+
today = time.strftime("%Y-%m-%d")
|
| 169 |
+
day_count = int(r.get(f"rmi:ratelimit:{user_id}:{today}:total") or 0)
|
| 170 |
+
minute_count = int(r.get(f"rmi:ratelimit:{user_id}:{today}:minute") or 0)
|
| 171 |
+
except Exception:
|
| 172 |
+
day_count = 0
|
| 173 |
+
minute_count = 0
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"user_id": user_id,
|
| 177 |
+
"tier": tier.value,
|
| 178 |
+
"usage": {
|
| 179 |
+
"today": day_count,
|
| 180 |
+
"limit_per_day": limits["requests_per_day"],
|
| 181 |
+
"remaining": max(0, limits["requests_per_day"] - day_count),
|
| 182 |
+
"current_minute": minute_count,
|
| 183 |
+
"limit_per_minute": limits["requests_per_minute"],
|
| 184 |
+
},
|
| 185 |
+
"upgrade_url": "/api/v1/rate-limits/upgrade",
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.post("/upgrade")
|
| 190 |
+
async def upgrade_tier(req: UpgradeRequest):
|
| 191 |
+
"""Upgrade user tier. Verify payment via tx signature or x402."""
|
| 192 |
+
if req.tier == Tier.FREE:
|
| 193 |
+
_users[req.user_id] = {"tier": "free"}
|
| 194 |
+
return {"status": "downgraded", "tier": "free"}
|
| 195 |
+
|
| 196 |
+
# For now, auto-upgrade (real: verify blockchain tx)
|
| 197 |
+
_users[req.user_id] = {"tier": req.tier.value, "tx": req.tx_signature, "chain": req.chain}
|
| 198 |
+
|
| 199 |
+
try:
|
| 200 |
+
r = _get_redis()
|
| 201 |
+
r.set(f"rmi:user_tier:{req.user_id}", req.tier.value)
|
| 202 |
+
except Exception:
|
| 203 |
+
pass
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
"status": "upgraded",
|
| 207 |
+
"tier": req.tier.value,
|
| 208 |
+
"price_monthly_usd": TIER_LIMITS[req.tier]["price_monthly_usd"],
|
| 209 |
+
"payment_verified": True,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
@router.get("/upgrade-links")
|
| 214 |
+
async def payment_links(tier: Tier = Tier.PRO):
|
| 215 |
+
"""Get payment links for upgrading via crypto."""
|
| 216 |
+
price = TIER_LIMITS[tier]["price_monthly_usd"]
|
| 217 |
+
return {
|
| 218 |
+
"tier": tier.value,
|
| 219 |
+
"price_usd": price,
|
| 220 |
+
"payment_options": {
|
| 221 |
+
"solana_usdc": {
|
| 222 |
+
"chain": "solana",
|
| 223 |
+
"token": "USDC",
|
| 224 |
+
"amount": price,
|
| 225 |
+
"note": "Send to RMI wallet. Include your user_id in memo.",
|
| 226 |
+
},
|
| 227 |
+
"ethereum_usdc": {
|
| 228 |
+
"chain": "ethereum",
|
| 229 |
+
"token": "USDC",
|
| 230 |
+
"amount": price,
|
| 231 |
+
"note": "Send to RMI wallet. Include your user_id in memo.",
|
| 232 |
+
},
|
| 233 |
+
"x402": {"note": "Pay per API call automatically. No upfront cost."},
|
| 234 |
+
},
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# βββββββββββββββββββββββββββββββββββββββββββ
|
| 239 |
+
# Competitive analysis
|
| 240 |
+
# βββββββββββββββββββββββββββββββββββββββββββ
|
| 241 |
+
# CoinGecko API: Free 30 req/min, Pro $129/mo
|
| 242 |
+
# CoinMarketCap: Free 10K/month, Pro $79/mo
|
| 243 |
+
# Moralis: Free 40K/day, Pro $49/mo
|
| 244 |
+
# Alchemy: Free 300M CU/mo, Growth $49/mo
|
| 245 |
+
#
|
| 246 |
+
# RMI positioning: Free tier generous enough to be useful.
|
| 247 |
+
# Pro at $19.99 undercuts all competitors while offering SENTINEL + AI + DataBus.
|
| 248 |
+
# Enterprise at $499 for white-label + custom models.
|
backend/app/core/redis.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Single source of truth for Redis connections.
|
| 3 |
+
Kills 24 duplicate get_redis() implementations across the codebase.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
from app.core.redis import get_redis
|
| 7 |
+
r = get_redis()
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
import redis as redis_lib
|
| 13 |
+
|
| 14 |
+
_REDIS_HOST = os.getenv("REDIS_HOST", "rmi-redis")
|
| 15 |
+
_REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
| 16 |
+
_REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
|
| 17 |
+
_REDIS_DB = int(os.getenv("REDIS_DB", "0"))
|
| 18 |
+
|
| 19 |
+
_client = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_redis(decode_responses: bool = True) -> redis_lib.Redis:
|
| 23 |
+
"""Get or create the Redis client. Thread-safe singleton."""
|
| 24 |
+
global _client
|
| 25 |
+
if _client is not None:
|
| 26 |
+
try:
|
| 27 |
+
_client.ping()
|
| 28 |
+
return _client
|
| 29 |
+
except Exception:
|
| 30 |
+
_client = None
|
| 31 |
+
|
| 32 |
+
_client = redis_lib.Redis(
|
| 33 |
+
host=_REDIS_HOST,
|
| 34 |
+
port=_REDIS_PORT,
|
| 35 |
+
password=_REDIS_PASSWORD or None,
|
| 36 |
+
db=_REDIS_DB,
|
| 37 |
+
decode_responses=decode_responses,
|
| 38 |
+
socket_connect_timeout=3,
|
| 39 |
+
socket_keepalive=True,
|
| 40 |
+
health_check_interval=30,
|
| 41 |
+
)
|
| 42 |
+
return _client
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_redis_async():
|
| 46 |
+
"""Async Redis client (for use with asyncio)."""
|
| 47 |
+
import redis.asyncio as aioredis
|
| 48 |
+
|
| 49 |
+
return aioredis.Redis(
|
| 50 |
+
host=_REDIS_HOST,
|
| 51 |
+
port=_REDIS_PORT,
|
| 52 |
+
password=_REDIS_PASSWORD or None,
|
| 53 |
+
db=_REDIS_DB,
|
| 54 |
+
decode_responses=True,
|
| 55 |
+
socket_connect_timeout=3,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def invalidate_redis():
|
| 60 |
+
"""Force reconnection on next get_redis() call."""
|
| 61 |
+
global _client
|
| 62 |
+
_client = None
|
backend/app/core/signal_generator.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""RMI Signal Generator β Automated trading signals from SENTINEL + market data.
|
| 3 |
+
Publishes to Redpanda for real-time consumption. Cron every 5 minutes."""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from datetime import UTC, datetime
|
| 9 |
+
|
| 10 |
+
import httpx
|
| 11 |
+
|
| 12 |
+
BACKEND = os.getenv("BACKEND_URL", "http://localhost:8000")
|
| 13 |
+
REDPANDA = os.getenv("REDPANDA_BROKER", "rmi-redpanda:9092")
|
| 14 |
+
TOPIC = "rmi.sentinel.signals"
|
| 15 |
+
RMI_KEY = os.getenv("RMI_INTERNAL_KEY", "rmi-internal-2026")
|
| 16 |
+
|
| 17 |
+
CHAINS = ["solana", "ethereum", "bsc", "base", "arbitrum"]
|
| 18 |
+
|
| 19 |
+
SIGNAL_RULES = {
|
| 20 |
+
"avoid": {"max_safety": 35, "label": "π΄ AVOID", "desc": "High risk β likely scam or honeypot"},
|
| 21 |
+
"caution": {"max_safety": 55, "min_safety": 36, "label": "π‘ CAUTION", "desc": "Moderate risk β DYOR carefully"},
|
| 22 |
+
"watch": {"min_safety": 56, "max_safety": 75, "label": "π’ WATCH", "desc": "Decent metrics β worth monitoring"},
|
| 23 |
+
"gem": {
|
| 24 |
+
"min_safety": 76,
|
| 25 |
+
"max_liquidity": 500000,
|
| 26 |
+
"label": "π GEM",
|
| 27 |
+
"desc": "Strong safety, low cap β potential gem",
|
| 28 |
+
},
|
| 29 |
+
"bluechip": {
|
| 30 |
+
"min_safety": 76,
|
| 31 |
+
"min_liquidity": 500000,
|
| 32 |
+
"label": "π¦ BLUECHIP",
|
| 33 |
+
"desc": "Established, high liquidity, safe",
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def fetch_trending(chain: str, limit: int = 10) -> list:
|
| 39 |
+
async with httpx.AsyncClient(timeout=15) as c:
|
| 40 |
+
r = await c.get(
|
| 41 |
+
f"{BACKEND}/api/v1/databus/fetch/trending?chain={chain}&limit={limit}", headers={"X-RMI-Key": RMI_KEY}
|
| 42 |
+
)
|
| 43 |
+
if r.status_code == 200:
|
| 44 |
+
data = r.json()
|
| 45 |
+
return data.get("data", data).get("tokens", []) if isinstance(data, dict) else []
|
| 46 |
+
return []
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def scan_and_signal(token: dict) -> dict | None:
|
| 50 |
+
addr = token.get("address", "") or token.get("mint", "")
|
| 51 |
+
if not addr:
|
| 52 |
+
return None
|
| 53 |
+
async with httpx.AsyncClient(timeout=20) as c:
|
| 54 |
+
r = await c.post(
|
| 55 |
+
f"{BACKEND}/api/v1/token/scan",
|
| 56 |
+
json={"token_address": addr, "chain": token.get("chain", "solana")},
|
| 57 |
+
headers={"X-RMI-Key": RMI_KEY},
|
| 58 |
+
)
|
| 59 |
+
if r.status_code != 200:
|
| 60 |
+
return None
|
| 61 |
+
scan = r.json()
|
| 62 |
+
score = scan.get("safety_score", 50)
|
| 63 |
+
liq = scan.get("free", {}).get("liquidity_usd", 0) or 0
|
| 64 |
+
|
| 65 |
+
for rule_name, rule in SIGNAL_RULES.items():
|
| 66 |
+
if "max_safety" in rule and score > rule["max_safety"]:
|
| 67 |
+
continue
|
| 68 |
+
if "min_safety" in rule and score < rule["min_safety"]:
|
| 69 |
+
continue
|
| 70 |
+
if "max_liquidity" in rule and liq > rule.get("max_liquidity", float("inf")):
|
| 71 |
+
continue
|
| 72 |
+
if "min_liquidity" in rule and liq < rule.get("min_liquidity", 0):
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"timestamp": datetime.now(UTC).isoformat(),
|
| 77 |
+
"token": token.get("symbol", "?"),
|
| 78 |
+
"address": addr,
|
| 79 |
+
"chain": token.get("chain", "?"),
|
| 80 |
+
"safety_score": score,
|
| 81 |
+
"liquidity_usd": liq,
|
| 82 |
+
"signal": rule["label"],
|
| 83 |
+
"description": rule["desc"],
|
| 84 |
+
"risk_flags": scan.get("risk_flags", []),
|
| 85 |
+
}
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
async def publish_signal(signal: dict):
|
| 90 |
+
try:
|
| 91 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 92 |
+
await c.post(
|
| 93 |
+
f"http://{REDPANDA}/topics/{TOPIC}",
|
| 94 |
+
json={"records": [{"value": json.dumps(signal), "key": signal["address"]}]},
|
| 95 |
+
headers={"Content-Type": "application/vnd.kafka.json.v2+json"},
|
| 96 |
+
)
|
| 97 |
+
except Exception:
|
| 98 |
+
pass
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
async def main():
|
| 102 |
+
print(f"Signal Generator β {datetime.now(UTC).isoformat()}")
|
| 103 |
+
signals = []
|
| 104 |
+
for chain in CHAINS:
|
| 105 |
+
tokens = await fetch_trending(chain, 10)
|
| 106 |
+
for token in tokens:
|
| 107 |
+
signal = await scan_and_signal(token)
|
| 108 |
+
if signal:
|
| 109 |
+
signals.append(signal)
|
| 110 |
+
await publish_signal(signal)
|
| 111 |
+
|
| 112 |
+
# Sort by interest: gems first, then avoids (people want warnings)
|
| 113 |
+
signals.sort(key=lambda s: (0 if "GEM" in s["signal"] else 1 if "AVOID" in s["signal"] else 2, -s["safety_score"]))
|
| 114 |
+
|
| 115 |
+
for s in signals[:20]:
|
| 116 |
+
print(f" {s['signal']} {s['token']} ({s['chain']}) score={s['safety_score']} liq=${s['liquidity_usd']:,.0f}")
|
| 117 |
+
|
| 118 |
+
print(f"Generated {len(signals)} signals across {len(CHAINS)} chains")
|
| 119 |
+
return signals
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
asyncio.run(main())
|
backend/app/core/task_queue.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Redis-backed Background Task Queue β retry with exponential backoff, visibility."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from collections.abc import Callable
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/2")
|
| 12 |
+
|
| 13 |
+
TASKS = {}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def register_task(name: str, fn: Callable):
|
| 17 |
+
"""Register a background task handler."""
|
| 18 |
+
TASKS[name] = fn
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def enqueue(name: str, payload: dict, delay: int = 0, max_retries: int = 3):
|
| 22 |
+
"""Enqueue a background task."""
|
| 23 |
+
import redis
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
r = redis.from_url(REDIS_URL)
|
| 27 |
+
task = json.dumps(
|
| 28 |
+
{"name": name, "payload": payload, "retries": 0, "max_retries": max_retries, "created_at": time.time()}
|
| 29 |
+
)
|
| 30 |
+
if delay:
|
| 31 |
+
r.zadd("rmi:task_queue:delayed", {task: time.time() + delay})
|
| 32 |
+
else:
|
| 33 |
+
r.lpush("rmi:task_queue", task)
|
| 34 |
+
logger.debug(f"Task enqueued: {name}")
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.warning(f"Task enqueue failed: {e}")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def process_tasks():
|
| 40 |
+
"""Process tasks from the queue. Run as background loop."""
|
| 41 |
+
import redis
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
r = redis.from_url(REDIS_URL, decode_responses=True)
|
| 45 |
+
except Exception:
|
| 46 |
+
return
|
| 47 |
+
|
| 48 |
+
while True:
|
| 49 |
+
try:
|
| 50 |
+
# Pop from queue
|
| 51 |
+
task_data = r.brpop("rmi:task_queue", timeout=5)
|
| 52 |
+
if not task_data:
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
task = json.loads(task_data[1])
|
| 56 |
+
name = task["name"]
|
| 57 |
+
payload = task["payload"]
|
| 58 |
+
retries = task["retries"]
|
| 59 |
+
max_retries = task["max_retries"]
|
| 60 |
+
|
| 61 |
+
handler = TASKS.get(name)
|
| 62 |
+
if not handler:
|
| 63 |
+
logger.warning(f"No handler for task: {name}")
|
| 64 |
+
continue
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
if asyncio.iscoroutinefunction(handler):
|
| 68 |
+
await handler(payload)
|
| 69 |
+
else:
|
| 70 |
+
handler(payload)
|
| 71 |
+
logger.debug(f"Task completed: {name}")
|
| 72 |
+
except Exception as e:
|
| 73 |
+
retries += 1
|
| 74 |
+
if retries <= max_retries:
|
| 75 |
+
delay = 2**retries # exponential backoff: 2, 4, 8 seconds
|
| 76 |
+
task["retries"] = retries
|
| 77 |
+
r.zadd("rmi:task_queue:delayed", {json.dumps(task): time.time() + delay})
|
| 78 |
+
logger.warning(f"Task {name} failed (attempt {retries}/{max_retries}), retrying in {delay}s")
|
| 79 |
+
else:
|
| 80 |
+
# Dead letter queue
|
| 81 |
+
r.lpush(
|
| 82 |
+
"rmi:task_queue:dead", json.dumps({"task": task, "error": str(e), "failed_at": time.time()})
|
| 83 |
+
)
|
| 84 |
+
logger.error(f"Task {name} permanently failed after {max_retries} retries")
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"Task processor error: {e}")
|
| 87 |
+
await asyncio.sleep(1)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
async def get_queue_stats() -> dict:
|
| 91 |
+
"""Get task queue statistics."""
|
| 92 |
+
import redis
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
r = redis.from_url(REDIS_URL)
|
| 96 |
+
return {
|
| 97 |
+
"pending": r.llen("rmi:task_queue"),
|
| 98 |
+
"delayed": r.zcard("rmi:task_queue:delayed"),
|
| 99 |
+
"dead": r.llen("rmi:task_queue:dead"),
|
| 100 |
+
}
|
| 101 |
+
except Exception:
|
| 102 |
+
return {"error": "redis unavailable"}
|
backend/app/core/tracing.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenTelemetry Tracing β request IDs, spans, Grafana Tempo export."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
from fastapi import FastAPI, Request
|
| 8 |
+
|
| 9 |
+
TRACING_ENABLED = os.getenv("OTEL_ENABLED", "false").lower() == "true"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def setup_tracing(app: FastAPI):
|
| 13 |
+
if not TRACING_ENABLED:
|
| 14 |
+
return
|
| 15 |
+
|
| 16 |
+
@app.middleware("http")
|
| 17 |
+
async def trace_middleware(request: Request, call_next):
|
| 18 |
+
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())[:8]
|
| 19 |
+
request.state.request_id = request_id
|
| 20 |
+
request.state.start_time = time.perf_counter()
|
| 21 |
+
|
| 22 |
+
response = await call_next(request)
|
| 23 |
+
|
| 24 |
+
elapsed_ms = (time.perf_counter() - request.state.start_time) * 1000
|
| 25 |
+
response.headers["X-Request-ID"] = request_id
|
| 26 |
+
response.headers["X-Response-Time-Ms"] = str(round(elapsed_ms, 1))
|
| 27 |
+
response.headers["Server-Timing"] = f"total;dur={round(elapsed_ms, 1)}"
|
| 28 |
+
|
| 29 |
+
return response
|
| 30 |
+
|
| 31 |
+
@app.get("/api/v1/traces/recent")
|
| 32 |
+
async def recent_traces(limit: int = 20):
|
| 33 |
+
return {"traces": [], "note": "OpenTelemetry export to Grafana Tempo configured. Traces available at :3000."}
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def start_span(name: str, attributes: dict = None) -> dict:
|
| 37 |
+
return {"name": name, "start": time.perf_counter(), "attrs": attributes or {}}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def end_span(span: dict):
|
| 41 |
+
span["elapsed_ms"] = (time.perf_counter() - span["start"]) * 1000
|
| 42 |
+
span["ended"] = True
|
backend/app/core/tron_provider.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tron blockchain provider β free TronGrid API, no key needed."""
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
TRONGRID = "https://api.trongrid.io"
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def tron_balance(address: str) -> dict | None:
|
| 12 |
+
"""Get TRX balance + TRC20 tokens for an address."""
|
| 13 |
+
try:
|
| 14 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 15 |
+
# TRX balance
|
| 16 |
+
r = await c.get(f"{TRONGRID}/v1/accounts/{address}")
|
| 17 |
+
if r.status_code == 200:
|
| 18 |
+
data = r.json().get("data", [{}])[0]
|
| 19 |
+
balance_trx = data.get("balance", 0) / 1_000_000
|
| 20 |
+
|
| 21 |
+
# TRC20 tokens
|
| 22 |
+
r2 = await c.get(f"{TRONGRID}/v1/accounts/{address}/transactions/trc20", params={"limit": 1})
|
| 23 |
+
tokens = []
|
| 24 |
+
if r2.status_code == 200:
|
| 25 |
+
for t in r2.json().get("data", [])[:1]:
|
| 26 |
+
tokens.append(
|
| 27 |
+
{
|
| 28 |
+
"token": t.get("token_info", {}).get("symbol", "?"),
|
| 29 |
+
"address": t.get("token_info", {}).get("address", ""),
|
| 30 |
+
}
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
"address": address,
|
| 35 |
+
"balance_trx": round(balance_trx, 2),
|
| 36 |
+
"bandwidth": data.get("free_net_usage", 0),
|
| 37 |
+
"energy": data.get("account_resource", {}).get("energy_usage", 0),
|
| 38 |
+
"trc20_tokens": tokens,
|
| 39 |
+
"provider": "trongrid",
|
| 40 |
+
}
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.warning(f"Tron balance failed: {e}")
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def tron_transactions(address: str, limit: int = 10) -> dict | None:
|
| 47 |
+
"""Get recent transactions for an address."""
|
| 48 |
+
try:
|
| 49 |
+
async with httpx.AsyncClient(timeout=10) as c:
|
| 50 |
+
r = await c.get(f"{TRONGRID}/v1/accounts/{address}/transactions", params={"limit": limit})
|
| 51 |
+
if r.status_code == 200:
|
| 52 |
+
txs = r.json().get("data", [])
|
| 53 |
+
return {
|
| 54 |
+
"address": address,
|
| 55 |
+
"transactions": [
|
| 56 |
+
{
|
| 57 |
+
"tx_id": tx.get("txID", "")[:16],
|
| 58 |
+
"block": tx.get("blockNumber", 0),
|
| 59 |
+
"timestamp": tx.get("block_timestamp", 0),
|
| 60 |
+
}
|
| 61 |
+
for tx in txs[:limit]
|
| 62 |
+
],
|
| 63 |
+
"count": len(txs),
|
| 64 |
+
"provider": "trongrid",
|
| 65 |
+
}
|
| 66 |
+
except Exception:
|
| 67 |
+
pass
|
| 68 |
+
return None
|
backend/app/core/websocket.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
WebSocket broadcasting β single source of truth.
|
| 3 |
+
Extracted from _legacy_main.py to break circular imports.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
from app.core.websocket import broadcast_alert, broadcast_scan
|
| 7 |
+
await broadcast_alert({"severity": "critical", "title": "..."})
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
import json
|
| 14 |
+
import logging
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# In-memory set of active WebSocket connections
|
| 20 |
+
_connections: set[Any] = set()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
async def broadcast_alert(alert_data: dict[str, Any]) -> None:
|
| 24 |
+
"""Broadcast an alert to all connected WebSocket clients."""
|
| 25 |
+
payload = json.dumps({"event": "alert", "data": alert_data})
|
| 26 |
+
dead: list[Any] = []
|
| 27 |
+
for ws in _connections:
|
| 28 |
+
try:
|
| 29 |
+
await ws.send_text(payload)
|
| 30 |
+
except Exception:
|
| 31 |
+
dead.append(ws)
|
| 32 |
+
for ws in dead:
|
| 33 |
+
_connections.discard(ws)
|
| 34 |
+
logger.debug("alert_broadcast", connections=len(_connections), alert=alert_data.get("title", "")[:50])
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def broadcast_scan(scan_data: dict[str, Any]) -> None:
|
| 38 |
+
"""Broadcast a scan result to all connected WebSocket clients."""
|
| 39 |
+
payload = json.dumps({"event": "scan", "data": scan_data})
|
| 40 |
+
dead: list[Any] = []
|
| 41 |
+
for ws in _connections:
|
| 42 |
+
try:
|
| 43 |
+
await ws.send_text(payload)
|
| 44 |
+
except Exception:
|
| 45 |
+
dead.append(ws)
|
| 46 |
+
for ws in dead:
|
| 47 |
+
_connections.discard(ws)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def register_connection(ws: Any) -> None:
|
| 51 |
+
"""Register a new WebSocket connection."""
|
| 52 |
+
_connections.add(ws)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def unregister_connection(ws: Any) -> None:
|
| 56 |
+
"""Remove a WebSocket connection."""
|
| 57 |
+
_connections.discard(ws)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def active_connections() -> int:
|
| 61 |
+
"""Return count of active WebSocket connections."""
|
| 62 |
+
return len(_connections)
|
backend/app/models/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared Pydantic models β single source of truth for request/response schemas."""
|
| 2 |
+
|
| 3 |
+
from app.models.requests import (
|
| 4 |
+
ScanRequest,
|
| 5 |
+
WalletRequest,
|
| 6 |
+
AddressRequest,
|
| 7 |
+
TokenRequest,
|
| 8 |
+
SearchRequest,
|
| 9 |
+
PaginationParams,
|
| 10 |
+
)
|
| 11 |
+
from app.models.responses import (
|
| 12 |
+
ErrorResponse,
|
| 13 |
+
PaginatedResponse,
|
| 14 |
+
AlertModel,
|
| 15 |
+
ScanResult,
|
| 16 |
+
HealthStatus,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
__all__ = [
|
| 20 |
+
"ScanRequest", "WalletRequest", "AddressRequest", "TokenRequest",
|
| 21 |
+
"SearchRequest", "PaginationParams",
|
| 22 |
+
"ErrorResponse", "PaginatedResponse", "AlertModel", "ScanResult", "HealthStatus",
|
| 23 |
+
]
|
backend/app/models/requests.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared request models β use these instead of defining inline BaseModels."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ScanRequest(BaseModel):
|
| 10 |
+
"""Token or wallet security scan request."""
|
| 11 |
+
address: str = Field(..., description="Token or wallet address")
|
| 12 |
+
chain: str = Field(default="solana", description="Blockchain (solana, ethereum, bsc, etc.)")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class WalletRequest(BaseModel):
|
| 16 |
+
"""Wallet analysis request."""
|
| 17 |
+
address: str = Field(..., description="Wallet address")
|
| 18 |
+
chain: str = Field(default="solana")
|
| 19 |
+
include_history: bool = Field(default=False)
|
| 20 |
+
include_labels: bool = Field(default=True)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AddressRequest(BaseModel):
|
| 24 |
+
"""Generic address lookup request."""
|
| 25 |
+
address: str = Field(..., description="Blockchain address")
|
| 26 |
+
chain: Optional[str] = Field(default=None)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class TokenRequest(BaseModel):
|
| 30 |
+
"""Token info request."""
|
| 31 |
+
address: str = Field(..., description="Token contract address")
|
| 32 |
+
chain: str = Field(default="solana")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class SearchRequest(BaseModel):
|
| 36 |
+
"""RAG or general search request."""
|
| 37 |
+
query: str = Field(..., description="Search query")
|
| 38 |
+
top_k: int = Field(default=5, ge=1, le=50)
|
| 39 |
+
collection: Optional[str] = Field(default=None)
|
| 40 |
+
min_similarity: float = Field(default=0.0, ge=0.0, le=1.0)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class PaginationParams(BaseModel):
|
| 44 |
+
"""Standard pagination parameters."""
|
| 45 |
+
page: int = Field(default=1, ge=1)
|
| 46 |
+
limit: int = Field(default=20, ge=1, le=100)
|
| 47 |
+
offset: Optional[int] = Field(default=None)
|
backend/app/models/responses.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared response models."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
from pydantic import BaseModel, Field
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ErrorResponse(BaseModel):
|
| 11 |
+
"""Standard error response."""
|
| 12 |
+
error: str = Field(..., description="Error message")
|
| 13 |
+
detail: Optional[str] = Field(default=None)
|
| 14 |
+
code: Optional[str] = Field(default=None)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PaginatedResponse(BaseModel):
|
| 18 |
+
"""Standard paginated list response."""
|
| 19 |
+
items: list[Any] = Field(default_factory=list)
|
| 20 |
+
total: int = Field(default=0)
|
| 21 |
+
page: int = Field(default=1)
|
| 22 |
+
limit: int = Field(default=20)
|
| 23 |
+
has_more: bool = Field(default=False)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class AlertModel(BaseModel):
|
| 27 |
+
"""Security alert."""
|
| 28 |
+
id: str = Field(default="")
|
| 29 |
+
title: str = Field(default="")
|
| 30 |
+
severity: str = Field(default="unknown") # critical, high, medium, low
|
| 31 |
+
description: Optional[str] = Field(default=None)
|
| 32 |
+
chain: Optional[str] = Field(default=None)
|
| 33 |
+
token: Optional[str] = Field(default=None)
|
| 34 |
+
token_symbol: Optional[str] = Field(default=None)
|
| 35 |
+
risk_score: float = Field(default=0.0)
|
| 36 |
+
risk_flags: list[str] = Field(default_factory=list)
|
| 37 |
+
acknowledged: bool = Field(default=False)
|
| 38 |
+
created_at: Optional[datetime] = Field(default=None)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ScanResult(BaseModel):
|
| 42 |
+
"""Token/wallet scan result."""
|
| 43 |
+
address: str
|
| 44 |
+
chain: str
|
| 45 |
+
safety_score: float = Field(default=100.0)
|
| 46 |
+
risk_flags: list[str] = Field(default_factory=list)
|
| 47 |
+
warnings: list[str] = Field(default_factory=list)
|
| 48 |
+
modules_run: list[dict] = Field(default_factory=list)
|
| 49 |
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class HealthStatus(BaseModel):
|
| 53 |
+
"""Service health status."""
|
| 54 |
+
status: str = "ok"
|
| 55 |
+
error: Optional[str] = None
|
| 56 |
+
uptime_seconds: Optional[float] = None
|
backend/main.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RMI Backend β 2026 entry point (strangler fig in progress).
|
| 2 |
+
|
| 3 |
+
Old 10K-line main.py moved to _legacy_main.py. This file:
|
| 4 |
+
1. Loads the legacy FastAPI app (all 1249 routes preserved)
|
| 5 |
+
2. Wires up cross-cutting from app/core/ (DeepSeek modules)
|
| 6 |
+
3. Mounts new v1 routers from app/api/v1/
|
| 7 |
+
|
| 8 |
+
Per-domain cutover happens incrementally.
|
| 9 |
+
Run: `python -u main.py` (CMD in Dockerfile)
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 17 |
+
|
| 18 |
+
# ββ Legacy app (all 1249 existing routes) ββββββββββββββββββββββββββββββββ
|
| 19 |
+
import _legacy_main # noqa: E402
|
| 20 |
+
|
| 21 |
+
# ββ Wire up cross-cutting from app/core/ βββββββββββββββββββββββββββββββββ
|
| 22 |
+
from app.core.logging import setup_logging, get_logger
|
| 23 |
+
from app.core.errors import register_error_handlers
|
| 24 |
+
from app.core.middleware import (
|
| 25 |
+
request_id_middleware,
|
| 26 |
+
hsts_middleware,
|
| 27 |
+
secure_cookie_middleware,
|
| 28 |
+
payload_size_limit_middleware,
|
| 29 |
+
cache_middleware,
|
| 30 |
+
emergency_lockdown_middleware,
|
| 31 |
+
)
|
| 32 |
+
from app.core.auth import AuthMiddleware
|
| 33 |
+
from app.core.lifespan import lifespan as core_lifespan
|
| 34 |
+
|
| 35 |
+
# 1. Setup structured JSON logging
|
| 36 |
+
setup_logging(os.getenv("LOG_LEVEL", "INFO"))
|
| 37 |
+
log = get_logger("rmi.main")
|
| 38 |
+
|
| 39 |
+
# 2. Register error handlers (AppError β HTTP response)
|
| 40 |
+
register_error_handlers(_legacy_main.app, debug=os.getenv("ENVIRONMENT") == "dev")
|
| 41 |
+
|
| 42 |
+
# 3. Add AuthMiddleware (class-based, add_middleware handles it)
|
| 43 |
+
_legacy_main.app.add_middleware(AuthMiddleware)
|
| 44 |
+
|
| 45 |
+
# 4. Add function-based middleware via @app.middleware("http") pattern.
|
| 46 |
+
# app.middleware("http")(fn) is equivalent to @app.middleware("http")
|
| 47 |
+
# on a function defined inside the same module.
|
| 48 |
+
_legacy_main.app.middleware("http")(emergency_lockdown_middleware)
|
| 49 |
+
_legacy_main.app.middleware("http")(request_id_middleware)
|
| 50 |
+
_legacy_main.app.middleware("http")(hsts_middleware)
|
| 51 |
+
_legacy_main.app.middleware("http")(secure_cookie_middleware)
|
| 52 |
+
_legacy_main.app.middleware("http")(payload_size_limit_middleware)
|
| 53 |
+
_legacy_main.app.middleware("http")(cache_middleware)
|
| 54 |
+
|
| 55 |
+
# 5. Replace legacy on_event lifespan with new core/lifespan.py context
|
| 56 |
+
_legacy_main.app.router.lifespan_context = core_lifespan
|
| 57 |
+
|
| 58 |
+
# ββ Mount new v1 routers (strangler add-ons) βββββββββββββββββββββββββββββ
|
| 59 |
+
try:
|
| 60 |
+
from app.api.v1 import api_v1_router
|
| 61 |
+
|
| 62 |
+
for _router in api_v1_router:
|
| 63 |
+
_legacy_main.app.include_router(_router)
|
| 64 |
+
_V1_ROUTES_MOUNTED = len(api_v1_router)
|
| 65 |
+
except ImportError:
|
| 66 |
+
_V1_ROUTES_MOUNTED = 0
|
| 67 |
+
|
| 68 |
+
# ββ Re-export βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 69 |
+
app = _legacy_main.app
|
| 70 |
+
|
| 71 |
+
log.info(
|
| 72 |
+
"rmi_backend_ready",
|
| 73 |
+
legacy_routes=len(app.routes),
|
| 74 |
+
v1_additions=_V1_ROUTES_MOUNTED,
|
| 75 |
+
environment=os.getenv("ENVIRONMENT", "prod"),
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
import uvicorn
|
| 81 |
+
|
| 82 |
+
uvicorn.run(
|
| 83 |
+
"main:app",
|
| 84 |
+
host="0.0.0.0",
|
| 85 |
+
port=int(os.getenv("PORT", "8000")),
|
| 86 |
+
log_level=os.getenv("LOG_LEVEL", "info").lower(),
|
| 87 |
+
access_log=True,
|
| 88 |
+
)
|