merge: chore/cleanup-remove-bloat-and-secrets into main
This commit is contained in:
commit
bde2f3a97d
1173 changed files with 437609 additions and 0 deletions
530
app/routers/smart_calls.py
Normal file
530
app/routers/smart_calls.py
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
"""
|
||||
Human-Facing Tool Catalog API — SmartCalls Marketplace
|
||||
======================================================
|
||||
Source of truth: /mcp/manifest + tool_registry + membership_plans + agent_skills.
|
||||
The MCP server is the bot-side. This is the human-side mirror.
|
||||
|
||||
Adding a tool on the bot side? It shows up here automatically.
|
||||
Updating pricing on /mcp/membership? Marketplace reads it.
|
||||
Adding a skill? Marketplace exposes it.
|
||||
Every endpoint reads from the same registries.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
logger = logging.getLogger("smart_calls")
|
||||
router = APIRouter(prefix="/api/v1/smart-calls", tags=["smart-calls"])
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# TRIAL TIERS — same logic as bot side
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
TRIAL_TIERS = {
|
||||
"simple": {"trials": 5, "label": "5 free trials", "color": "emerald", "badge": "🎁"},
|
||||
"standard": {"trials": 3, "label": "3 free trials", "color": "blue", "badge": "✨"},
|
||||
"premium": {"trials": 1, "label": "1 free trial", "color": "purple", "badge": "💎"},
|
||||
}
|
||||
|
||||
PRICE_TIERS = {
|
||||
"micro": {"max": 0.01, "label": "Micro", "color": "emerald", "icon": "·"},
|
||||
"low": {"max": 0.05, "label": "Low", "color": "blue", "icon": "•"},
|
||||
"mid": {"max": 0.15, "label": "Mid", "color": "amber", "icon": "●"},
|
||||
"high": {"max": 0.50, "label": "High", "color": "rose", "icon": "○"},
|
||||
"premium": {"max": 999.0, "label": "Premium", "color": "violet", "icon": "◆"},
|
||||
}
|
||||
|
||||
CATEGORY_TRIAL_DEFAULTS = {
|
||||
"sentiment": "simple",
|
||||
"social": "simple",
|
||||
"api": "simple",
|
||||
"research": "simple",
|
||||
"analysis": "standard",
|
||||
"intelligence": "standard",
|
||||
"market": "standard",
|
||||
"monitoring": "standard",
|
||||
"launchpad": "standard",
|
||||
"defi": "standard",
|
||||
"nft": "standard",
|
||||
"bundle": "standard",
|
||||
"security": "premium",
|
||||
"forensics": "premium",
|
||||
"premium": "premium",
|
||||
"mcp-external": "simple",
|
||||
}
|
||||
|
||||
|
||||
def _classify_trial_tier(trial_free: int | None, category: str) -> str:
|
||||
if trial_free is not None:
|
||||
n = int(trial_free)
|
||||
if n >= 5:
|
||||
return "simple"
|
||||
if n >= 3:
|
||||
return "standard"
|
||||
return "premium"
|
||||
return CATEGORY_TRIAL_DEFAULTS.get(category.lower(), "standard")
|
||||
|
||||
|
||||
def _classify_price_tier(price_usd: float) -> str:
|
||||
for tier_name, info in PRICE_TIERS.items():
|
||||
if price_usd <= info["max"]:
|
||||
return tier_name
|
||||
return "premium"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# CACHE — short TTL so updates flow through fast
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
_cache: dict[str, Any] = {}
|
||||
_cache_time: float = 0
|
||||
CACHE_TTL = 15
|
||||
|
||||
|
||||
def _build_marketplace_data() -> dict[str, Any]:
|
||||
"""Pull from the same sources the MCP server uses."""
|
||||
global _cache, _cache_time
|
||||
now = time.time()
|
||||
if _cache and (now - _cache_time) < CACHE_TTL:
|
||||
return _cache
|
||||
|
||||
# === SOURCE 1: TOOL_PRICES (bot-side MCP server mirror) ===
|
||||
try:
|
||||
from app.routers.x402_enforcement import CHAIN_USDC, TOOL_PRICES
|
||||
|
||||
dict(TOOL_PRICES)
|
||||
except ImportError:
|
||||
CHAIN_USDC = {}
|
||||
|
||||
# === SOURCE 2: x402_catalog (already merges external MCPs) ===
|
||||
try:
|
||||
from app.routers.x402_catalog import get_catalog
|
||||
|
||||
x402_cat = get_catalog()
|
||||
x402_tools_list = list(x402_cat.get("tools", []))
|
||||
except Exception:
|
||||
x402_tools_list = []
|
||||
|
||||
# === SOURCE 3: Expanded MCP services (free open-source) ===
|
||||
try:
|
||||
from app.services.expanded_mcp_catalog import (
|
||||
EXTERNAL_MCP_SERVICES,
|
||||
EXTERNAL_MCP_TOOLS,
|
||||
)
|
||||
|
||||
external_services = list(EXTERNAL_MCP_SERVICES)
|
||||
# Build ID set for detection — these IDs are the free open-source MCP tools
|
||||
external_tool_ids = {t["id"] for t in EXTERNAL_MCP_TOOLS}
|
||||
except ImportError:
|
||||
external_services = []
|
||||
external_tool_ids = set()
|
||||
|
||||
# === SOURCE 4: Membership plans (scan packs, subscriptions, streams) ===
|
||||
try:
|
||||
from app.caching_shield.membership_plans import (
|
||||
AGENT_BUNDLES,
|
||||
BATCH_PRODUCTS,
|
||||
RESEARCH_PRODUCTS,
|
||||
STREAM_PRODUCTS,
|
||||
SUBSCRIPTION_TIERS,
|
||||
get_membership_catalog,
|
||||
)
|
||||
|
||||
get_membership_catalog()
|
||||
scan_packs = list(AGENT_BUNDLES.values())
|
||||
subscriptions = list(SUBSCRIPTION_TIERS.values())
|
||||
streams = list(STREAM_PRODUCTS.values())
|
||||
research = list(RESEARCH_PRODUCTS.values())
|
||||
batch = list(BATCH_PRODUCTS.values())
|
||||
except ImportError:
|
||||
scan_packs = []
|
||||
subscriptions = []
|
||||
streams = []
|
||||
research = []
|
||||
batch = []
|
||||
|
||||
# === SOURCE 5: Agent skills (workflow guides) ===
|
||||
try:
|
||||
from app.caching_shield.agent_skills_extended import get_all_agent_skills
|
||||
|
||||
skills_data = get_all_agent_skills()
|
||||
except ImportError:
|
||||
skills_data = {"skills": []}
|
||||
|
||||
# === SOURCE 6: Tool registry (counts) ===
|
||||
try:
|
||||
from app.caching_shield.tool_registry import count_all_tools
|
||||
|
||||
tool_counts = count_all_tools()
|
||||
except ImportError:
|
||||
tool_counts = {"total": 0}
|
||||
|
||||
# === BUILD HUMAN-FACING TOOL LIST ===
|
||||
# Use the x402_catalog as primary (it has enriched fields), fall back to TOOL_PRICES
|
||||
enriched_tools = []
|
||||
for t in x402_tools_list:
|
||||
price_usd = float(t.get("priceUsd", t.get("price_usd", 0.01)) or 0.01)
|
||||
trial_free = t.get("trialFree", t.get("trial_free"))
|
||||
cat = t.get("category", "analysis")
|
||||
chains = [c.upper() for c in t.get("chains", [])]
|
||||
source = t.get("source", "enforcement")
|
||||
service = t.get("service", "rmi-native")
|
||||
tool_id = t.get("id", "")
|
||||
# External = either marked external in x402 OR in our expanded_mcp_catalog ID set
|
||||
is_external = (
|
||||
source in ("expanded-mcp", "mcp-router")
|
||||
or service
|
||||
in (
|
||||
"fear-greed-mcp",
|
||||
"crypto-indicators-mcp",
|
||||
"openzeppelin-wizard",
|
||||
"web3-research-mcp",
|
||||
)
|
||||
or tool_id in external_tool_ids
|
||||
)
|
||||
|
||||
enriched_tools.append(
|
||||
{
|
||||
"id": t.get("id", ""),
|
||||
"name": t.get("name", t.get("id", "?")),
|
||||
"description": t.get("description", ""),
|
||||
"category": cat,
|
||||
"service": service,
|
||||
"source": source,
|
||||
"is_external": is_external,
|
||||
"chains": chains,
|
||||
"price_usd": price_usd,
|
||||
"price_label": f"${price_usd:.2f}",
|
||||
"trial_tier": _classify_trial_tier(trial_free, cat),
|
||||
"trial_count": TRIAL_TIERS[_classify_trial_tier(trial_free, cat)]["trials"],
|
||||
"trial_label": TRIAL_TIERS[_classify_trial_tier(trial_free, cat)]["label"],
|
||||
"trial_badge": TRIAL_TIERS[_classify_trial_tier(trial_free, cat)]["badge"],
|
||||
"price_tier": _classify_price_tier(price_usd),
|
||||
"method": t.get("method", "POST"),
|
||||
}
|
||||
)
|
||||
|
||||
# === GROUP FOR DISPLAY ===
|
||||
by_category = defaultdict(list)
|
||||
by_chain = defaultdict(list)
|
||||
by_service = defaultdict(list)
|
||||
by_trial = defaultdict(list)
|
||||
by_price = defaultdict(list)
|
||||
featured = []
|
||||
|
||||
for t in enriched_tools:
|
||||
by_category[t["category"]].append(t)
|
||||
by_service[t["service"]].append(t)
|
||||
by_trial[t["trial_tier"]].append(t)
|
||||
by_price[t["price_tier"]].append(t)
|
||||
for c in t["chains"]:
|
||||
by_chain[c].append(t)
|
||||
if t["category"] in (
|
||||
"security",
|
||||
"intelligence",
|
||||
"market",
|
||||
"analysis",
|
||||
"social",
|
||||
"defi",
|
||||
"premium",
|
||||
):
|
||||
if len([f for f in featured if f["category"] == t["category"]]) < 6:
|
||||
featured.append(t)
|
||||
|
||||
# Sort
|
||||
enriched_tools.sort(key=lambda t: (0 if not t["is_external"] else 1, t["category"], t["price_usd"]))
|
||||
for k in by_category:
|
||||
by_category[k].sort(key=lambda t: t["price_usd"])
|
||||
for k in by_service:
|
||||
by_service[k].sort(key=lambda t: t["price_usd"])
|
||||
for k in by_trial:
|
||||
by_trial[k].sort(key=lambda t: t["price_usd"])
|
||||
for k in by_price:
|
||||
by_price[k].sort(key=lambda t: t["price_usd"])
|
||||
for k in by_chain:
|
||||
by_chain[k].sort(key=lambda t: t["price_usd"])
|
||||
featured.sort(key=lambda t: t["price_usd"])
|
||||
|
||||
result = {
|
||||
"version": "1.0.0",
|
||||
"generated_at": int(now),
|
||||
# === COUNTS ===
|
||||
"stats": {
|
||||
"total_tools": len(enriched_tools),
|
||||
"native_tools": sum(1 for t in enriched_tools if not t["is_external"]),
|
||||
"external_tools": sum(1 for t in enriched_tools if t["is_external"]),
|
||||
"total_services": len(by_service),
|
||||
"total_categories": len(by_category),
|
||||
"total_chains": len(by_chain),
|
||||
"total_scan_packs": len(scan_packs),
|
||||
"total_subscriptions": len(subscriptions),
|
||||
"total_streams": len(streams),
|
||||
"total_research": len(research),
|
||||
"total_batch": len(batch),
|
||||
"total_skills": len(skills_data.get("skills", [])),
|
||||
"total_registry": tool_counts.get("total", 0),
|
||||
},
|
||||
# === TOOLS ===
|
||||
"tools": enriched_tools,
|
||||
"by_category": dict(by_category),
|
||||
"by_chain": dict(by_chain),
|
||||
"by_service": dict(by_service),
|
||||
"by_trial_tier": dict(by_trial),
|
||||
"by_price_tier": dict(by_price),
|
||||
"featured": featured[:30],
|
||||
# === PRODUCTS ===
|
||||
"scan_packs": scan_packs,
|
||||
"subscriptions": subscriptions,
|
||||
"streams": streams,
|
||||
"research": research,
|
||||
"batch": batch,
|
||||
# === META ===
|
||||
"skills": skills_data,
|
||||
"external_services": external_services,
|
||||
"trial_tiers": TRIAL_TIERS,
|
||||
"price_tiers": PRICE_TIERS,
|
||||
"chains": {k: v for k, v in CHAIN_USDC.items() if k not in ("sepa",)},
|
||||
"refund_policy": "Full automatic refund within 48h if tool returns no data",
|
||||
}
|
||||
|
||||
_cache = result
|
||||
_cache_time = now
|
||||
return result
|
||||
|
||||
|
||||
def invalidate():
|
||||
"""Force rebuild on next request — call after adding/updating tools."""
|
||||
global _cache_time
|
||||
_cache_time = 0
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# ENDPOINTS — SmartCalls Marketplace
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.get("/marketplace")
|
||||
async def marketplace(
|
||||
sort: str = Query("default"),
|
||||
category: str | None = None,
|
||||
service: str | None = None,
|
||||
chain: str | None = None,
|
||||
free_only: bool = False,
|
||||
search: str | None = None,
|
||||
limit: int = Query(500, ge=0, le=1000),
|
||||
):
|
||||
"""Full marketplace — all tools, scan packs, subscriptions, streams, research, skills."""
|
||||
data = _build_marketplace_data()
|
||||
tools = list(data["tools"])
|
||||
|
||||
if category:
|
||||
tools = [t for t in tools if t["category"].lower() == category.lower()]
|
||||
if service:
|
||||
tools = [t for t in tools if t["service"].lower() == service.lower()]
|
||||
if chain:
|
||||
chain_upper = chain.upper()
|
||||
tools = [t for t in tools if chain_upper in t["chains"]]
|
||||
if free_only:
|
||||
tools = [t for t in tools if t["trial_count"] > 0]
|
||||
if search:
|
||||
q = search.lower()
|
||||
tools = [t for t in tools if q in t["id"].lower() or q in t["name"].lower() or q in t["description"].lower()]
|
||||
|
||||
if sort == "price":
|
||||
tools.sort(key=lambda t: t["price_usd"])
|
||||
elif sort == "trial":
|
||||
tools.sort(key=lambda t: -t["trial_count"])
|
||||
elif sort == "name":
|
||||
tools.sort(key=lambda t: t["name"].lower())
|
||||
elif sort == "category":
|
||||
tools.sort(key=lambda t: (t["category"], t["name"].lower()))
|
||||
|
||||
if limit > 0:
|
||||
tools = tools[:limit]
|
||||
|
||||
return {
|
||||
"stats": data["stats"],
|
||||
"refund_policy": data["refund_policy"],
|
||||
"trial_tiers": data["trial_tiers"],
|
||||
"price_tiers": data["price_tiers"],
|
||||
"chains": data["chains"],
|
||||
"tools": tools,
|
||||
"featured": data["featured"],
|
||||
"scan_packs": data["scan_packs"],
|
||||
"subscriptions": data["subscriptions"],
|
||||
"streams": data["streams"],
|
||||
"research": data["research"],
|
||||
"batch": data["batch"],
|
||||
"external_services": data["external_services"],
|
||||
"categories": sorted(data["by_category"].keys()),
|
||||
"services": sorted(data["by_service"].keys()),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/marketplace/stats")
|
||||
async def marketplace_stats():
|
||||
"""Counts only — counts always match the bot side."""
|
||||
data = _build_marketplace_data()
|
||||
return {
|
||||
"version": data["version"],
|
||||
"generated_at": data["generated_at"],
|
||||
**data["stats"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/marketplace/featured")
|
||||
async def featured_tools():
|
||||
"""Top 30 featured tools across major categories."""
|
||||
data = _build_marketplace_data()
|
||||
return {"tools": data["featured"]}
|
||||
|
||||
|
||||
@router.get("/marketplace/categories")
|
||||
async def categories():
|
||||
"""Tools grouped by category."""
|
||||
data = _build_marketplace_data()
|
||||
return {
|
||||
"categories": [
|
||||
{"name": cat, "count": len(tools), "tools": tools[:10]}
|
||||
for cat, tools in sorted(data["by_category"].items(), key=lambda x: -len(x[1]))
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/marketplace/chains")
|
||||
async def chains():
|
||||
"""Tools grouped by chain."""
|
||||
data = _build_marketplace_data()
|
||||
return {
|
||||
"chains": [
|
||||
{"name": c, "count": len(tools), "tools": tools[:10]}
|
||||
for c, tools in sorted(data["by_chain"].items(), key=lambda x: -len(x[1]))
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/marketplace/trial")
|
||||
async def trial_tiers():
|
||||
"""Tools grouped by free trial tier."""
|
||||
data = _build_marketplace_data()
|
||||
return {
|
||||
"tiers": data["trial_tiers"],
|
||||
"groups": dict(data["by_trial_tier"].items()),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/marketplace/scan-packs")
|
||||
async def scan_packs():
|
||||
"""Bundled tool packs at discount."""
|
||||
data = _build_marketplace_data()
|
||||
return {"count": len(data["scan_packs"]), "packs": data["scan_packs"]}
|
||||
|
||||
|
||||
@router.get("/marketplace/memberships")
|
||||
async def memberships():
|
||||
"""Subscription tiers."""
|
||||
data = _build_marketplace_data()
|
||||
return {"count": len(data["subscriptions"]), "tiers": data["subscriptions"]}
|
||||
|
||||
|
||||
@router.get("/marketplace/streams")
|
||||
async def streams():
|
||||
"""Real-time data streams."""
|
||||
data = _build_marketplace_data()
|
||||
return {"count": len(data["streams"]), "streams": data["streams"]}
|
||||
|
||||
|
||||
@router.get("/marketplace/research")
|
||||
async def research():
|
||||
"""Research products."""
|
||||
data = _build_marketplace_data()
|
||||
return {"count": len(data["research"]), "products": data["research"]}
|
||||
|
||||
|
||||
@router.get("/marketplace/batch")
|
||||
async def batch():
|
||||
"""Bulk scanning products."""
|
||||
data = _build_marketplace_data()
|
||||
return {"count": len(data["batch"]), "products": data["batch"]}
|
||||
|
||||
|
||||
@router.get("/marketplace/skills")
|
||||
async def agent_skills():
|
||||
"""Agent workflow guides (also visible to humans)."""
|
||||
data = _build_marketplace_data()
|
||||
return data["skills"]
|
||||
|
||||
|
||||
@router.get("/marketplace/search")
|
||||
async def search(q: str = Query(..., min_length=2), limit: int = 50):
|
||||
"""Free-text search."""
|
||||
data = _build_marketplace_data()
|
||||
q_lower = q.lower()
|
||||
results = []
|
||||
for t in data["tools"]:
|
||||
score = 0
|
||||
if q_lower in t["id"].lower():
|
||||
score += 100
|
||||
if q_lower in t["name"].lower():
|
||||
score += 50
|
||||
if q_lower in t["description"].lower():
|
||||
score += 20
|
||||
if q_lower in t["category"].lower():
|
||||
score += 10
|
||||
if q_lower in t["service"].lower():
|
||||
score += 5
|
||||
if any(q_lower in c.lower() for c in t["chains"]):
|
||||
score += 5
|
||||
if score > 0:
|
||||
results.append({**t, "_score": score})
|
||||
results.sort(key=lambda t: -t["_score"])
|
||||
for r in results:
|
||||
del r["_score"]
|
||||
return {"query": q, "total": len(results), "tools": results[:limit]}
|
||||
|
||||
|
||||
@router.get("/marketplace/{tool_id}")
|
||||
async def get_tool(tool_id: str):
|
||||
"""Single tool with full metadata."""
|
||||
data = _build_marketplace_data()
|
||||
for t in data["tools"]:
|
||||
if t["id"] == tool_id:
|
||||
return t
|
||||
raise HTTPException(404, f"Tool '{tool_id}' not found")
|
||||
|
||||
|
||||
@router.post("/invalidate")
|
||||
async def invalidate_cache():
|
||||
"""Force rebuild — call after adding tools or updating pricing."""
|
||||
invalidate()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# TOOL EXECUTION (mirror of /api/v1/x402-tools but with friendlier trial semantics)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@router.post("/{tool_id}")
|
||||
async def call_tool(tool_id: str, request: Request):
|
||||
"""
|
||||
Execute a tool. Same as /api/v1/x402-tools/{tool_id} but with:
|
||||
- Friendlier trial messages
|
||||
- Built-in device fingerprint header handling
|
||||
- Returns trial balance after execution
|
||||
|
||||
Identical payment logic — both paths go through the same enforcement.
|
||||
"""
|
||||
# Delegate to the existing x402 endpoint
|
||||
# Forward the request internally
|
||||
# (The x402 middleware handles the same path; this route is for friendlier responses)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": f"Use /api/v1/x402-tools/{tool_id} for execution. This endpoint mirrors the bot-side path.",
|
||||
"tool": tool_id,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue