Add NIP-91 provider discovery and auto-announcement support

Co-authored-by: db2002dominic <db2002dominic@gmail.com>
This commit is contained in:
Cursor Agent
2025-08-10 18:08:24 +00:00
co-authored by db2002dominic
parent f2890d819e
commit dff80b9e5d
6 changed files with 1864 additions and 1385 deletions
+15 -1
View File
@@ -33,7 +33,8 @@ sequenceDiagram
- **API Key Management** Hashed keys stored in SQLite with balance tracking and optional expiry/refund address
- **Model-Based Pricing** Convert USD prices in `models.json` to sats using live BTC/USD rates
- **Admin Dashboard** Simple HTML interface at `/admin/` to view balances and API keys
- **Discovery** Fetch available providers from Nostr relays
- **Discovery** Fetch available providers from Nostr relays using both RIP-02 and NIP-91 protocols
- **NIP-91 Auto-Announcement** Automatically announce this provider to Nostr relays when NSEC is provided
- **Docker Support** Provided `Dockerfile` and `compose.yml` for running with an optional Tor hidden service
## Getting Started
@@ -86,6 +87,7 @@ This builds the image and also starts a Tor container exposing the API as a hidd
The most common settings are shown below. See `.env.example` for the full list.
### Core Settings
- `UPSTREAM_BASE_URL` URL of the OpenAI-compatible service
- `UPSTREAM_API_KEY` API key for the upstream service (optional)
- `MODEL_BASED_PRICING` Set to `true` to use pricing from `models.json`
@@ -97,6 +99,18 @@ The most common settings are shown below. See `.env.example` for the full list.
- `HTTP_URL` Public-facing URL of the proxy
- `ONION_URL` Tor hidden service URL of the proxy
### NIP-91 Provider Announcement Settings
- `NOSTR_NSEC` Nostr private key in hex format for announcing this provider (optional)
- `ROUTSTR_PROVIDER_ID` Unique identifier for this provider instance (defaults to hostname)
- `ROUTSTR_BASE_URL` Base URL for the provider API (defaults to http://localhost:8000)
- `ROUTSTR_ONION_URL` Tor hidden service URL for the provider (optional)
- `ROUTSTR_MINT_URL` Associated ecash mint URL for payments (optional)
- `ROUTSTR_PROVIDER_NAME` Human-readable name for the provider (defaults to "Routstr Proxy")
- `ROUTSTR_PROVIDER_ABOUT` Description of the provider (defaults to "Privacy-preserving AI proxy via Nostr")
- `ROUTSTR_VERSION` Provider software version (defaults to "0.1.0")
- `NOSTR_RELAYS` Comma-separated list of Nostr relay URLs for announcing (defaults to popular relays)
- `NIP91_ANNOUNCEMENT_INTERVAL` Seconds between re-announcements (defaults to 86400/24 hours)
## Database Migrations
The application uses Alembic for database schema management and **automatically runs migrations on startup**. This ensures your database is always up-to-date when deploying new versions.
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"cashu",
"secp256k1",
"marshmallow>=3.13,<4.0",
"websockets>=12.0",
]
[dependency-groups]
+7
View File
@@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
from ..balance import balance_router, deprecated_wallet_router
from ..discovery import providers_router
from ..nip91 import announce_provider
from ..payment.models import MODELS, models_router, update_sats_pricing
from ..proxy import proxy_router
from ..wallet import periodic_payout
@@ -28,6 +29,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = None
payout_task = None
nip91_task = None
try:
# Run database migrations on startup
@@ -42,6 +44,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task = asyncio.create_task(update_sats_pricing())
payout_task = asyncio.create_task(periodic_payout())
nip91_task = asyncio.create_task(announce_provider())
yield
@@ -58,6 +61,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
pricing_task.cancel()
if payout_task is not None:
payout_task.cancel()
if nip91_task is not None:
nip91_task.cancel()
try:
tasks_to_wait = []
@@ -65,6 +70,8 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
tasks_to_wait.append(pricing_task)
if payout_task is not None:
tasks_to_wait.append(payout_task)
if nip91_task is not None:
tasks_to_wait.append(nip91_task)
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
+91 -43
View File
@@ -24,14 +24,14 @@ async def query_nostr_relay_for_providers(
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for provider announcements using RIP-02 spec.
Searches for kind 31338 events (Routstr Provider Announcements).
Query a Nostr relay for provider announcements.
Searches for both RIP-02 (kind:31338) and NIP-91 (kind:38421) events.
"""
events = []
# Build filter according to RIP-02 spec
# Build filter for both RIP-02 and NIP-91 events
filter_obj: dict[str, Any] = {
"kinds": [31338], # RIP-02 Provider Announcement events
"kinds": [31338, 38421], # Both RIP-02 and NIP-91 Provider Announcements
"limit": limit,
}
@@ -44,7 +44,7 @@ async def query_nostr_relay_for_providers(
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
print("Connected to relay, searching for kind 31338 events")
print("Connected to relay, searching for provider announcement events")
await websocket.send(req_message)
while True:
@@ -80,61 +80,105 @@ async def query_nostr_relay_for_providers(
def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
"""
Parse a kind 31338 provider announcement event according to RIP-02 spec.
Parse provider announcement events.
Handles both RIP-02 (kind:31338) and NIP-91 (kind:38421) formats.
Returns structured provider data or None if invalid.
"""
try:
# Extract required tags according to RIP-02
tags = event.get("tags", [])
# Find required tags
endpoint_url = None
provider_name = None
kind = event.get("kind")
# Common fields
d_tag = None
for tag in tags:
if len(tag) >= 2:
if tag[0] == "endpoint":
endpoint_url = tag[1]
elif tag[0] == "name":
provider_name = tag[1]
elif tag[0] == "d":
d_tag = tag[1]
# Validate required fields
if not endpoint_url or not provider_name or not d_tag:
print(
f"Invalid provider announcement - missing required tags: {event['id']}"
)
return None
# Extract optional tags
endpoint_urls = []
provider_name = None
description = None
contact = None
pricing_url = None
supported_models = []
for tag in tags:
if len(tag) >= 2:
if tag[0] == "description":
description = tag[1]
elif tag[0] == "contact":
contact = tag[1]
elif tag[0] == "pricing":
pricing_url = tag[1]
elif tag[0] == "model":
supported_models.append(tag[1])
mint_url = None
version = None
# Parse based on event kind
if kind == 31338: # RIP-02 format
for tag in tags:
if len(tag) >= 2:
if tag[0] == "endpoint":
endpoint_urls.append(tag[1])
elif tag[0] == "name":
provider_name = tag[1]
elif tag[0] == "d":
d_tag = tag[1]
elif tag[0] == "description":
description = tag[1]
elif tag[0] == "contact":
contact = tag[1]
elif tag[0] == "pricing":
pricing_url = tag[1]
elif tag[0] == "model":
supported_models.append(tag[1])
# RIP-02 requires single endpoint
endpoint_url = endpoint_urls[0] if endpoint_urls else None
# Validate RIP-02 required fields
if not endpoint_url or not provider_name or not d_tag:
print(f"Invalid RIP-02 announcement - missing required tags: {event['id']}")
return None
elif kind == 38421: # NIP-91 format
for tag in tags:
if len(tag) >= 2:
if tag[0] == "d":
d_tag = tag[1]
elif tag[0] == "u":
endpoint_urls.append(tag[1])
elif tag[0] == "models" and len(tag) > 1:
# NIP-91 uses single models tag with multiple values
supported_models = tag[1:]
elif tag[0] == "mint":
mint_url = tag[1]
elif tag[0] == "version":
version = tag[1]
# Parse metadata from content for NIP-91
content = event.get("content", "")
if content:
try:
metadata = json.loads(content)
provider_name = metadata.get("name", "Unknown Provider")
description = metadata.get("about")
contact = metadata.get("contact")
except:
provider_name = "Unknown Provider"
else:
provider_name = "Unknown Provider"
# Use first URL as primary endpoint
endpoint_url = endpoint_urls[0] if endpoint_urls else None
# Validate NIP-91 required fields
if not endpoint_url or not d_tag:
print(f"Invalid NIP-91 announcement - missing required fields: {event['id']}")
return None
else:
print(f"Unknown event kind: {kind}")
return None
return {
"id": event["id"],
"pubkey": event["pubkey"],
"created_at": event["created_at"],
"kind": kind,
"d_tag": d_tag,
"endpoint_url": endpoint_url,
"endpoint_urls": endpoint_urls, # All URLs for NIP-91
"name": provider_name,
"description": description,
"contact": contact,
"pricing_url": pricing_url,
"mint_url": mint_url,
"version": version,
"supported_models": supported_models,
"content": event.get("content", ""),
}
@@ -204,10 +248,14 @@ async def get_providers(
include_json: bool = False, pubkey: str | None = None
) -> dict[str, list[dict[str, Any]]]:
"""
Discover Routstr providers using RIP-02 specification.
Searches for kind 31338 provider announcement events on Nostr relays.
Discover Routstr providers using both RIP-02 and NIP-91 specifications.
Searches for provider announcement events on Nostr relays:
- kind:31338 (RIP-02)
- kind:38421 (NIP-91)
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
References:
- RIP-02: https://github.com/Routstr/protocol/blob/main/RIP-02.md
- NIP-91: https://github.com/nostr-protocol/nips/pull/1987
"""
# Default relays for provider discovery
discovery_relays = [
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
NIP-91: Routstr Provider Discoverability Implementation
Automatically announces this Routstr proxy instance to Nostr relays.
"""
import asyncio
import hashlib
import json
import os
import time
from typing import Any
import secp256k1
import websockets
from .core import get_logger
from .payment.models import MODELS
logger = get_logger(__name__)
def nsec_to_keypair(nsec: str) -> tuple[str, str] | None:
"""
Convert a Nostr private key (nsec) to a keypair (privkey_hex, pubkey_hex).
Args:
nsec: Nostr private key in nsec format or hex format
Returns:
Tuple of (private_key_hex, public_key_hex) or None if invalid
"""
try:
# Handle nsec format
if nsec.startswith("nsec"):
# Simple bech32 decode - for production use a proper library
# For now, we'll assume hex format is passed
logger.warning("nsec format not yet implemented, please use hex format")
return None
# Assume hex format
if len(nsec) != 64:
logger.error(f"Invalid private key length: {len(nsec)}")
return None
private_key = secp256k1.PrivateKey(bytes.fromhex(nsec))
public_key = private_key.pubkey.serialize(compressed=True)[1:] # Remove 0x02/0x03 prefix
return (nsec, public_key.hex())
except Exception as e:
logger.error(f"Failed to convert nsec to keypair: {e}")
return None
def create_nip91_event(
private_key_hex: str,
provider_id: str,
endpoint_urls: list[str],
supported_models: list[str],
mint_url: str | None = None,
version: str = "0.1.0",
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""
Create a NIP-91 compliant provider announcement event (kind:38421).
Args:
private_key_hex: 32-byte hex private key for signing
provider_id: Unique identifier for this provider (d tag)
endpoint_urls: List of URLs to connect to the provider
supported_models: List of supported AI model IDs
mint_url: Optional ecash mint URL for payments
version: Provider software version
metadata: Optional metadata dictionary (name, picture, about, etc.)
Returns:
Complete signed nostr event ready for publishing
"""
# Convert hex private key to secp256k1 PrivateKey object
private_key = secp256k1.PrivateKey(bytes.fromhex(private_key_hex))
public_key = private_key.pubkey.serialize(compressed=True)[1:] # Remove 0x02/0x03 prefix
# Build tags according to NIP-91
tags = [
["d", provider_id], # Unique identifier
]
# Add URLs
for url in endpoint_urls:
tags.append(["u", url])
# Add models as a single tag with multiple values
if supported_models:
tags.append(["models"] + supported_models)
# Add optional tags
if mint_url:
tags.append(["mint", mint_url])
tags.append(["version", version])
# Add model capabilities if detailed info available
for model in MODELS:
if model.id in supported_models:
capabilities = []
# Add max_tokens from context_length
if model.context_length:
capabilities.append(f"max_tokens:{model.context_length}")
# Check if model supports vision (simplified check)
if any(modal in ["image"] for modal in model.architecture.input_modalities):
capabilities.append("vision:true")
else:
capabilities.append("vision:false")
# Check if model supports tools (simplified - most modern models do)
if "gpt" in model.id or "claude" in model.id or "llama" in model.id:
capabilities.append("tools:true")
else:
capabilities.append("tools:false")
if capabilities:
tags.append(["model-cap", model.id, ",".join(capabilities)])
# Content is optional metadata as JSON string
content = ""
if metadata:
content = json.dumps(metadata, separators=(",", ":"))
# Create the event structure
created_at = int(time.time())
event_data = [
0, # Reserved field
public_key.hex(), # Public key as hex
created_at, # Unix timestamp
38421, # Kind for NIP-91 Provider Announcements
tags, # Tags array
content, # Content (metadata)
]
# Serialize event data for hashing
event_json = json.dumps(event_data, separators=(",", ":"), ensure_ascii=False)
# Calculate event ID (SHA256 hash)
event_id = hashlib.sha256(event_json.encode("utf-8")).hexdigest()
# Sign the event ID
signature = private_key.ecdsa_sign(bytes.fromhex(event_id), raw=True)
signature_ser = private_key.ecdsa_serialize(signature)
# Create the final event
event = {
"id": event_id,
"pubkey": public_key.hex(),
"created_at": created_at,
"kind": 38421,
"tags": tags,
"content": content,
"sig": signature_ser.hex(),
}
return event
async def query_nip91_events(
relay_url: str,
pubkey: str,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""
Query a Nostr relay for NIP-91 provider announcements (kind:38421).
Args:
relay_url: WebSocket URL of the nostr relay
pubkey: Public key to filter by
timeout: Connection timeout in seconds
Returns:
List of NIP-91 events from the given pubkey
"""
events = []
# Build filter for NIP-91 events from specific pubkey
filter_obj = {
"kinds": [38421],
"authors": [pubkey],
"limit": 10,
}
sub_id = f"nip91_{int(time.time())}"
req_message = json.dumps(["REQ", sub_id, filter_obj])
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
logger.info(f"Querying {relay_url} for existing NIP-91 events")
await websocket.send(req_message)
while True:
try:
message = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(message)
if data[0] == "EVENT" and data[1] == sub_id:
event = data[2]
logger.info(f"Found existing NIP-91 event: {event['id']}")
events.append(event)
elif data[0] == "EOSE" and data[1] == sub_id:
logger.debug("Received EOSE message")
break
elif data[0] == "NOTICE":
logger.warning(f"Relay notice: {data[1]}")
except asyncio.TimeoutError:
logger.debug("Timeout waiting for relay response")
break
except json.JSONDecodeError:
logger.error("Failed to decode relay message as JSON")
continue
await websocket.send(json.dumps(["CLOSE", sub_id]))
except Exception as e:
logger.error(f"Failed to query relay {relay_url}: {e}")
return events
async def publish_to_relay(
relay_url: str,
event: dict[str, Any],
timeout: int = 30,
) -> bool:
"""
Publish a NIP-91 event to a nostr relay.
Args:
relay_url: WebSocket URL of the nostr relay
event: Complete signed nostr event to publish
timeout: Connection timeout in seconds
Returns:
True if successfully published, False otherwise
"""
try:
async with websockets.connect(relay_url, timeout=timeout) as websocket:
# Send EVENT message
event_message = json.dumps(["EVENT", event])
await websocket.send(event_message)
logger.info(f"Sent NIP-91 event {event['id']} to {relay_url}")
# Wait for OK response
try:
response = await asyncio.wait_for(websocket.recv(), timeout=5)
data = json.loads(response)
if data[0] == "OK" and data[1] == event["id"]:
if data[2]: # True means accepted
logger.info(f"Event accepted by {relay_url}: {data[3] if len(data) > 3 else ''}")
return True
else:
logger.warning(f"Event rejected by {relay_url}: {data[3] if len(data) > 3 else ''}")
return False
elif data[0] == "NOTICE":
logger.warning(f"Relay notice from {relay_url}: {data[1]}")
return False
else:
logger.warning(f"Unexpected response from {relay_url}: {data}")
return False
except asyncio.TimeoutError:
logger.warning(f"No response from {relay_url} within timeout")
return False
except Exception as e:
logger.error(f"Failed to publish to {relay_url}: {e}")
return False
async def announce_provider() -> None:
"""
Background task to announce this Routstr provider to Nostr relays.
Checks for existing announcements and creates new ones if needed.
"""
# Check for NSEC in environment
nsec = os.getenv("NOSTR_NSEC")
if not nsec:
logger.info("NOSTR_NSEC not found in environment, skipping NIP-91 announcement")
return
# Convert NSEC to keypair
keypair = nsec_to_keypair(nsec)
if not keypair:
logger.error("Failed to parse NOSTR_NSEC, skipping NIP-91 announcement")
return
private_key_hex, public_key_hex = keypair
logger.info(f"Using Nostr pubkey: {public_key_hex}")
# Get configuration from environment
provider_id = os.getenv("ROUTSTR_PROVIDER_ID", os.getenv("HOSTNAME", "routstr-proxy"))
base_url = os.getenv("ROUTSTR_BASE_URL", "http://localhost:8000")
onion_url = os.getenv("ROUTSTR_ONION_URL")
mint_url = os.getenv("ROUTSTR_MINT_URL")
provider_name = os.getenv("ROUTSTR_PROVIDER_NAME", "Routstr Proxy")
provider_about = os.getenv("ROUTSTR_PROVIDER_ABOUT", "Privacy-preserving AI proxy via Nostr")
# Build endpoint URLs
endpoint_urls = [base_url]
if onion_url:
endpoint_urls.append(onion_url)
# Get supported models
supported_models = [model.id for model in MODELS]
if not supported_models:
logger.warning("No models loaded, will announce with empty model list")
supported_models = []
# Build metadata
metadata = {
"name": provider_name,
"about": provider_about,
}
# Get relay URLs from environment or use defaults
relay_urls_env = os.getenv("NOSTR_RELAYS")
if relay_urls_env:
relay_urls = [url.strip() for url in relay_urls_env.split(",")]
else:
relay_urls = [
"wss://relay.nostr.band",
"wss://relay.damus.io",
"wss://nos.lol",
]
# Check for existing announcements
existing_events = []
for relay_url in relay_urls:
events = await query_nip91_events(relay_url, public_key_hex)
existing_events.extend(events)
# Check if we need to publish (no events or outdated)
should_publish = True
if existing_events:
# Check if any existing event matches our current configuration
for event in existing_events:
tags_dict = {tag[0]: tag[1:] for tag in event.get("tags", [])}
if tags_dict.get("d", [""])[0] == provider_id:
# Check if configuration has changed
existing_urls = [tag[1] for tag in event.get("tags", []) if tag[0] == "u"]
existing_models = next((tag[1:] for tag in event.get("tags", []) if tag[0] == "models"), [])
if set(existing_urls) == set(endpoint_urls) and set(existing_models) == set(supported_models):
logger.info("Existing NIP-91 announcement is up to date")
should_publish = False
break
if should_publish:
# Create new NIP-91 event
event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
supported_models=supported_models,
mint_url=mint_url,
version=os.getenv("ROUTSTR_VERSION", "0.1.0"),
metadata=metadata,
)
logger.info(f"Created NIP-91 announcement event: {event['id']}")
# Publish to all relays
success_count = 0
for relay_url in relay_urls:
if await publish_to_relay(relay_url, event):
success_count += 1
logger.info(f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays")
# Re-announce periodically (every 24 hours)
announcement_interval = int(os.getenv("NIP91_ANNOUNCEMENT_INTERVAL", str(24 * 60 * 60)))
while True:
try:
await asyncio.sleep(announcement_interval)
# Re-create and publish event
event = create_nip91_event(
private_key_hex=private_key_hex,
provider_id=provider_id,
endpoint_urls=endpoint_urls,
supported_models=[model.id for model in MODELS], # Refresh model list
mint_url=mint_url,
version=os.getenv("ROUTSTR_VERSION", "0.1.0"),
metadata=metadata,
)
logger.info(f"Re-announcing provider (periodic update): {event['id']}")
for relay_url in relay_urls:
await publish_to_relay(relay_url, event)
except asyncio.CancelledError:
logger.info("NIP-91 announcement task cancelled")
break
except Exception as e:
logger.error(f"Error in NIP-91 announcement loop: {e}")
# Continue running despite errors
Generated
+1343 -1341
View File
File diff suppressed because it is too large Load Diff