mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-11 11:47:50 +00:00
Merge branch 'cursor/discover-and-announce-routstr-providers-3b9a' into dev
This commit is contained in:
@@ -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 NIP-91 protocol
|
||||
- **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,8 @@ 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`
|
||||
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./logs:/app/logs
|
||||
- tor-data:/var/lib/tor:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
|
||||
+11
-5
@@ -5,6 +5,7 @@ Routstr Core integrates with Nostr (Notes and Other Stuff Transmitted by Relays)
|
||||
## Overview
|
||||
|
||||
Nostr integration provides:
|
||||
|
||||
- **Decentralized Discovery**: Find providers through relay network
|
||||
- **Cryptographic Identity**: Providers identified by public keys
|
||||
- **Real-time Updates**: Live provider status and pricing
|
||||
@@ -34,6 +35,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien
|
||||
### Setting Up Nostr Identity
|
||||
|
||||
1. **Generate Nostr Keys**
|
||||
|
||||
```bash
|
||||
# Using nostril or similar tool
|
||||
nostril --generate-keypair
|
||||
@@ -44,6 +46,7 @@ Providers announce themselves by publishing signed events to Nostr relays. Clien
|
||||
```
|
||||
|
||||
2. **Configure Environment**
|
||||
|
||||
```bash
|
||||
# .env
|
||||
NPUB=npub1xyz... # Your public key
|
||||
@@ -104,7 +107,7 @@ DEFAULT_RELAYS = [
|
||||
]
|
||||
|
||||
# Custom relay configuration
|
||||
NOSTR_RELAYS=wss://relay1.com,wss://relay2.com
|
||||
RELAYS=wss://relay1.com,wss://relay2.com
|
||||
```
|
||||
|
||||
## Client Discovery
|
||||
@@ -276,6 +279,7 @@ def select_provider(
|
||||
### Automatic Updates
|
||||
|
||||
Routstr publishes updates when:
|
||||
|
||||
- Node starts up
|
||||
- Configuration changes
|
||||
- Models are added/removed
|
||||
@@ -295,7 +299,7 @@ async def publish_provider_info():
|
||||
pricing=get_current_pricing()
|
||||
)
|
||||
|
||||
await publish_to_relays(event, NOSTR_RELAYS)
|
||||
await publish_to_relays(event, RELAYS)
|
||||
```
|
||||
|
||||
### Event Lifecycle
|
||||
@@ -315,7 +319,7 @@ async def update_nostr_presence():
|
||||
async def remove_nostr_presence():
|
||||
"""Remove provider from discovery."""
|
||||
deletion_event = create_deletion_event()
|
||||
await publish_to_relays(deletion_event, NOSTR_RELAYS)
|
||||
await publish_to_relays(deletion_event, RELAYS)
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
@@ -323,6 +327,7 @@ async def remove_nostr_presence():
|
||||
### Key Management
|
||||
|
||||
1. **Secure Storage**
|
||||
|
||||
```python
|
||||
# Never log private keys
|
||||
SENSITIVE_VARS = ['NSEC', 'ADMIN_PASSWORD']
|
||||
@@ -335,6 +340,7 @@ async def remove_nostr_presence():
|
||||
```
|
||||
|
||||
2. **Key Rotation**
|
||||
|
||||
```bash
|
||||
# Generate new keys
|
||||
nostril --generate-keypair
|
||||
@@ -524,7 +530,7 @@ async def debug_discovery():
|
||||
issues = []
|
||||
|
||||
# Check relay connectivity
|
||||
for relay in NOSTR_RELAYS:
|
||||
for relay in RELAYS:
|
||||
if not await can_connect_to_relay(relay):
|
||||
issues.append(f"Cannot connect to {relay}")
|
||||
|
||||
@@ -589,4 +595,4 @@ wscat -c wss://relay.damus.io
|
||||
|
||||
- [Tor Support](tor.md) - Anonymous provider access
|
||||
- [Custom Pricing](custom-pricing.md) - Dynamic pricing strategies
|
||||
- [API Reference](../api/endpoints.md) - Discovery API details
|
||||
- [API Reference](../api/endpoints.md) - Discovery API details
|
||||
|
||||
@@ -16,6 +16,7 @@ dependencies = [
|
||||
"cashu",
|
||||
"secp256k1",
|
||||
"marshmallow>=3.13,<4.0",
|
||||
"websockets>=12.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -293,14 +293,19 @@ def setup_logging() -> None:
|
||||
"handlers": ["console"] if console_enabled else [],
|
||||
"propagate": False,
|
||||
},
|
||||
"websockets": {
|
||||
"level": "WARNING",
|
||||
"handlers": [],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": log_level, # Use the configured log level instead of WARNING
|
||||
"handlers": handlers, # Use both console and file handlers
|
||||
"level": "WARNING",
|
||||
"handlers": ["file"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"level": log_level, # Use the configured log level
|
||||
"handlers": handlers, # Use both console and file handlers
|
||||
"level": log_level,
|
||||
"handlers": handlers,
|
||||
"propagate": False,
|
||||
},
|
||||
"watchfiles.main": {"level": "WARNING", "handlers": [], "propagate": False},
|
||||
|
||||
@@ -10,6 +10,7 @@ from starlette.exceptions import HTTPException
|
||||
|
||||
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
|
||||
@@ -32,6 +33,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
pricing_task = None
|
||||
payout_task = None
|
||||
nip91_task = None
|
||||
|
||||
try:
|
||||
# Run database migrations on startup
|
||||
@@ -46,6 +48,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
|
||||
|
||||
@@ -62,6 +65,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 = []
|
||||
@@ -69,6 +74,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)
|
||||
|
||||
+83
-34
@@ -28,14 +28,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 NIP-91 (kind:38421) events.
|
||||
"""
|
||||
events = []
|
||||
|
||||
# Build filter according to RIP-02 spec
|
||||
# Build filter for NIP-91 events
|
||||
filter_obj: dict[str, Any] = {
|
||||
"kinds": [31338], # RIP-02 Provider Announcement events
|
||||
"kinds": [38421], # NIP-91 Provider Announcements
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
@@ -47,13 +47,13 @@ async def query_nostr_relay_for_providers(
|
||||
req_message = json.dumps(["REQ", sub_id, filter_obj])
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, timeout=timeout) as websocket:
|
||||
logger.debug("Connected to relay, searching for kind 31338 events")
|
||||
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
|
||||
logger.debug("Connected to relay, searching for NIP-91 events (kind 38421)")
|
||||
await websocket.send(req_message)
|
||||
|
||||
while True:
|
||||
try:
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=5)
|
||||
message = await asyncio.wait_for(websocket.recv(), timeout=50)
|
||||
data = json.loads(message)
|
||||
|
||||
if data[0] == "EVENT" and data[1] == sub_id:
|
||||
@@ -84,17 +84,19 @@ 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 NIP-91 (kind:38421) format.
|
||||
Returns structured provider data or None if invalid.
|
||||
"""
|
||||
try:
|
||||
# Extract required tags according to RIP-02
|
||||
tags = event.get("tags", [])
|
||||
kind = event.get("kind")
|
||||
|
||||
# Find required tags
|
||||
endpoint_url = None
|
||||
provider_name = None
|
||||
# Common fields
|
||||
d_tag = None
|
||||
endpoint_urls = []
|
||||
provider_name = None
|
||||
endpoint_url = None
|
||||
|
||||
for tag in tags:
|
||||
if len(tag) >= 2:
|
||||
@@ -105,8 +107,8 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
elif tag[0] == "d":
|
||||
d_tag = tag[1]
|
||||
|
||||
# Validate required fields
|
||||
if not endpoint_url or not provider_name or not d_tag:
|
||||
# Early validation only applies to legacy/other kinds, not NIP-91
|
||||
if kind != 38421 and (not endpoint_url or not provider_name or not d_tag):
|
||||
logger.warning(
|
||||
f"Invalid provider announcement - missing required tags: {event['id']}"
|
||||
)
|
||||
@@ -117,34 +119,75 @@ def parse_provider_announcement(event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
contact = None
|
||||
pricing_url = None
|
||||
supported_models = []
|
||||
mint_url = None
|
||||
version = None
|
||||
|
||||
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])
|
||||
# Parse NIP-91 format
|
||||
if 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 (json.JSONDecodeError, TypeError):
|
||||
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:
|
||||
logger.warning(
|
||||
f"Invalid NIP-91 announcement - missing required fields: {event['id']}"
|
||||
)
|
||||
return None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Unknown event kind when parsing provider announcement: {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", ""),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}")
|
||||
logger.error(
|
||||
f"Error parsing provider announcement {event.get('id', 'unknown')}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -208,17 +251,23 @@ 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 NIP-91 specification.
|
||||
Searches for provider announcement events on Nostr relays:
|
||||
- kind:38421 (NIP-91)
|
||||
|
||||
Reference: https://github.com/Routstr/protocol/blob/main/RIP-02.md
|
||||
References:
|
||||
- NIP-91: https://github.com/nostr-protocol/nips/pull/1987
|
||||
"""
|
||||
# Default relays for provider discovery
|
||||
discovery_relays = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
]
|
||||
# Configure relays: use RELAYS or defaults
|
||||
relays_env = os.getenv("RELAYS") or ""
|
||||
discovery_relays = [r.strip() for r in relays_env.split(",") if r.strip()]
|
||||
if not discovery_relays:
|
||||
discovery_relays = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
]
|
||||
|
||||
all_events = []
|
||||
event_ids = set() # To avoid duplicates
|
||||
@@ -247,7 +296,7 @@ async def get_providers(
|
||||
|
||||
logger.info(f"Found {len(all_events)} total unique provider announcements")
|
||||
|
||||
# Parse provider announcements according to RIP-02
|
||||
# Parse provider announcements according to NIP-91
|
||||
providers = []
|
||||
for event in all_events:
|
||||
parsed_provider = parse_provider_announcement(event)
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
#!/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, cast
|
||||
|
||||
import secp256k1
|
||||
import websockets
|
||||
|
||||
from .core import get_logger
|
||||
from .payment.models import MODELS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_app_version() -> str | None:
|
||||
try:
|
||||
from .core.main import __version__ as imported_version
|
||||
|
||||
return imported_version
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _schnorr_sign_event_id(
|
||||
private_key: secp256k1.PrivateKey, event_id_hex: str
|
||||
) -> bytes:
|
||||
"""Return 64-byte Schnorr signature over the 32-byte event id."""
|
||||
msg32 = bytes.fromhex(event_id_hex)
|
||||
|
||||
# Try common API variants exposed by python-secp256k1 bindings
|
||||
method = getattr(private_key, "schnorr_sign", None)
|
||||
if callable(method):
|
||||
try:
|
||||
sig = method(msg32)
|
||||
if isinstance(sig, bytes) and len(sig) == 64:
|
||||
return sig
|
||||
except TypeError:
|
||||
pass
|
||||
try:
|
||||
sig = method(msg32, None, True)
|
||||
if isinstance(sig, bytes) and len(sig) == 64:
|
||||
return sig
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
method32 = getattr(private_key, "schnorr_sign32", None)
|
||||
if callable(method32):
|
||||
sig = method32(msg32)
|
||||
if isinstance(sig, bytes) and len(sig) == 64:
|
||||
return sig
|
||||
|
||||
raise RuntimeError("Schnorr signing not available in secp256k1 binding")
|
||||
|
||||
|
||||
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))
|
||||
pubkey_obj = cast(secp256k1.PublicKey, private_key.pubkey)
|
||||
public_key = pubkey_obj.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 | None = None,
|
||||
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))
|
||||
pubkey_obj = cast(secp256k1.PublicKey, private_key.pubkey)
|
||||
public_key = pubkey_obj.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])
|
||||
if version:
|
||||
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 using Schnorr (BIP-340)
|
||||
sig_hex = _schnorr_sign_event_id(private_key, event_id).hex()
|
||||
|
||||
# Create the final event
|
||||
event = {
|
||||
"id": event_id,
|
||||
"pubkey": public_key.hex(),
|
||||
"created_at": created_at,
|
||||
"kind": 38421,
|
||||
"tags": tags,
|
||||
"content": content,
|
||||
"sig": sig_hex,
|
||||
}
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def _get_tag_values(event: dict[str, Any], key: str) -> list[str]:
|
||||
tags = event.get("tags", [])
|
||||
values: list[str] = []
|
||||
for tag in tags:
|
||||
if isinstance(tag, list) and tag and tag[0] == key and len(tag) >= 2:
|
||||
values.append(tag[1])
|
||||
return values
|
||||
|
||||
|
||||
def _get_single_tag_value(event: dict[str, Any], key: str) -> str | None:
|
||||
values = _get_tag_values(event, key)
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def _parse_content_json(content: str) -> dict[str, Any]:
|
||||
if not content:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(content)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def events_semantically_equal(a: dict[str, Any], b: dict[str, Any]) -> bool:
|
||||
if a.get("kind") != b.get("kind"):
|
||||
return False
|
||||
|
||||
if _get_single_tag_value(a, "d") != _get_single_tag_value(b, "d"):
|
||||
return False
|
||||
|
||||
urls_a = set(_get_tag_values(a, "u"))
|
||||
urls_b = set(_get_tag_values(b, "u"))
|
||||
if urls_a != urls_b:
|
||||
return False
|
||||
|
||||
if _get_single_tag_value(a, "mint") != _get_single_tag_value(b, "mint"):
|
||||
return False
|
||||
|
||||
if _get_single_tag_value(a, "version") != _get_single_tag_value(b, "version"):
|
||||
return False
|
||||
|
||||
content_a = _parse_content_json(cast(str, a.get("content", "")))
|
||||
content_b = _parse_content_json(cast(str, b.get("content", "")))
|
||||
if content_a != content_b:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def query_nip91_events(
|
||||
relay_url: str,
|
||||
pubkey: str,
|
||||
provider_id: str | None = None,
|
||||
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: dict[str, Any] = {
|
||||
"kinds": [38421],
|
||||
"authors": [pubkey],
|
||||
"limit": 10,
|
||||
}
|
||||
if provider_id:
|
||||
filter_obj["#d"] = [provider_id]
|
||||
|
||||
sub_id = f"nip91_{int(time.time())}"
|
||||
req_message = json.dumps(["REQ", sub_id, filter_obj])
|
||||
|
||||
try:
|
||||
async with websockets.connect(relay_url, open_timeout=timeout) as websocket:
|
||||
logger.debug(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.debug(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.debug("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
|
||||
|
||||
|
||||
def discover_onion_url_from_tor(base_dir: str = "/var/lib/tor") -> str | None:
|
||||
"""Discover onion URL by reading Tor hidden service hostname files.
|
||||
|
||||
Tries common paths first, then scans recursively for any 'hostname' file.
|
||||
Returns an http URL like 'http://<host>.onion' if found.
|
||||
"""
|
||||
common_candidates = [
|
||||
os.path.join(base_dir, "hs", "router", "hostname"),
|
||||
os.path.join(base_dir, "hs", "ROUTER", "hostname"),
|
||||
os.path.join(base_dir, "hidden_service", "hostname"),
|
||||
]
|
||||
|
||||
for candidate in common_candidates:
|
||||
try:
|
||||
with open(candidate, "r", encoding="utf-8") as f:
|
||||
host = f.readline().strip()
|
||||
if host and host.endswith(".onion"):
|
||||
return f"http://{host}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for root, _dirs, files in os.walk(base_dir):
|
||||
if "hostname" in files:
|
||||
path = os.path.join(root, "hostname")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
host = f.readline().strip()
|
||||
if host and host.endswith(".onion"):
|
||||
return f"http://{host}"
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _determine_provider_id(public_key_hex: str, relay_urls: list[str]) -> str:
|
||||
explicit = os.getenv("PROVIDER_ID") or os.getenv("NIP91_PROVIDER_ID")
|
||||
if explicit:
|
||||
logger.info(f"Using configured provider_id from env: {explicit}")
|
||||
return explicit
|
||||
|
||||
latest_event: dict[str, Any] | None = None
|
||||
latest_ts = -1
|
||||
for relay_url in relay_urls:
|
||||
try:
|
||||
events = await query_nip91_events(relay_url, public_key_hex, None)
|
||||
for ev in events:
|
||||
ts = int(ev.get("created_at", 0))
|
||||
if ts > latest_ts:
|
||||
latest_event = ev
|
||||
latest_ts = ts
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
existing_d = _get_single_tag_value(latest_event, "d") if latest_event else None
|
||||
if existing_d:
|
||||
logger.info(f"Reusing existing provider_id from relay: {existing_d}")
|
||||
return existing_d
|
||||
|
||||
fallback = public_key_hex[:12]
|
||||
logger.info(f"No existing provider_id found; using fallback: {fallback}")
|
||||
return fallback
|
||||
|
||||
|
||||
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, open_timeout=timeout) as websocket:
|
||||
# Send EVENT message
|
||||
event_message = json.dumps(["EVENT", event])
|
||||
await websocket.send(event_message)
|
||||
logger.debug(f"Sent NIP-91 event {event['id']} to {relay_url}")
|
||||
|
||||
# Wait for OK response
|
||||
try:
|
||||
response = await asyncio.wait_for(websocket.recv(), timeout=50)
|
||||
data = json.loads(response)
|
||||
logger.debug(f"Relay response: {data}")
|
||||
|
||||
if data[0] == "OK" and data[1] == event["id"]:
|
||||
if data[2]: # True means accepted
|
||||
logger.info(f"Event accepted by {relay_url}")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Event rejected by {relay_url}")
|
||||
return False
|
||||
elif data[0] == "NOTICE":
|
||||
logger.warning(f"Relay notice from {relay_url}: {data[1]}")
|
||||
return False
|
||||
else:
|
||||
logger.debug(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 (use NSEC only)
|
||||
nsec = os.getenv("NSEC")
|
||||
if not nsec:
|
||||
logger.info("Nostr private key not found (NSEC), skipping NIP-91 announcement")
|
||||
return
|
||||
|
||||
# Convert NSEC to keypair
|
||||
keypair = nsec_to_keypair(nsec)
|
||||
if not keypair:
|
||||
logger.error("Failed to parse NSEC, skipping NIP-91 announcement")
|
||||
return
|
||||
|
||||
private_key_hex, public_key_hex = keypair
|
||||
logger.info(f"Using Nostr pubkey: {public_key_hex}")
|
||||
|
||||
# Configure relays first (RELAYS only)
|
||||
relay_urls_env = os.getenv("RELAYS") or ""
|
||||
logger.debug(f"Configured relays: {relay_urls_env}")
|
||||
relay_urls = [url.strip() for url in relay_urls_env.split(",") if url.strip()]
|
||||
if not relay_urls:
|
||||
relay_urls = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
]
|
||||
|
||||
# Determine a stable provider_id
|
||||
provider_id = await _determine_provider_id(public_key_hex, relay_urls)
|
||||
logger.info(f"Using provider_id: {provider_id}")
|
||||
|
||||
# Core settings only (no ROUTSTR_* vars)
|
||||
base_url = os.getenv("HTTP_URL")
|
||||
onion_url = os.getenv("ONION_URL")
|
||||
if not onion_url:
|
||||
discovered = discover_onion_url_from_tor()
|
||||
if discovered:
|
||||
onion_url = discovered
|
||||
logger.info(f"Discovered onion URL via Tor volume: {onion_url}")
|
||||
provider_name = os.getenv("NAME", "Routstr Proxy")
|
||||
provider_about = os.getenv("DESCRIPTION", "Privacy-preserving AI proxy via Nostr")
|
||||
# Mint URL optional: first CASHU_MINTS entry if available
|
||||
cashu_mints = [
|
||||
m.strip() for m in os.getenv("CASHU_MINTS", "").split(",") if m.strip()
|
||||
]
|
||||
mint_url = cashu_mints[0] if cashu_mints else None
|
||||
|
||||
# Build endpoint URLs (skip defaults like localhost)
|
||||
endpoint_urls: list[str] = []
|
||||
if base_url and base_url.strip() and base_url.strip() != "http://localhost:8000":
|
||||
endpoint_urls.append(base_url.strip())
|
||||
if onion_url and onion_url.strip():
|
||||
endpoint_urls.append(onion_url.strip())
|
||||
|
||||
if not endpoint_urls:
|
||||
logger.warning(
|
||||
"No valid endpoints configured (HTTP_URL/ONION_URL). Skipping NIP-91 publish."
|
||||
)
|
||||
return
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
# Create the candidate event that we would publish
|
||||
version_str = get_app_version()
|
||||
candidate_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=version_str,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# Fetch existing events for this provider_id
|
||||
existing_events: list[dict[str, Any]] = []
|
||||
for relay_url in relay_urls:
|
||||
events = await query_nip91_events(relay_url, public_key_hex, provider_id)
|
||||
existing_events.extend(events)
|
||||
|
||||
# Decide whether to publish: publish if none exist or any differ from candidate
|
||||
found_any = len(existing_events) > 0
|
||||
all_match = found_any and all(
|
||||
events_semantically_equal(ev, candidate_event) for ev in existing_events
|
||||
)
|
||||
|
||||
if not all_match:
|
||||
logger.debug(
|
||||
"No matching NIP-91 announcement found or differences detected; publishing update"
|
||||
)
|
||||
success_count = 0
|
||||
for relay_url in relay_urls:
|
||||
if await publish_to_relay(relay_url, candidate_event):
|
||||
success_count += 1
|
||||
logger.info(
|
||||
f"Published NIP-91 announcement to {success_count}/{len(relay_urls)} relays"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Matching NIP-91 announcement already present; skipping publish on startup"
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
# Build fresh candidate event for comparison
|
||||
version_str = get_app_version()
|
||||
candidate_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],
|
||||
mint_url=mint_url,
|
||||
version=version_str,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# Fetch existing events for this provider_id
|
||||
existing_events = []
|
||||
for relay_url in relay_urls:
|
||||
events = await query_nip91_events(
|
||||
relay_url, public_key_hex, provider_id
|
||||
)
|
||||
existing_events.extend(events)
|
||||
|
||||
found_any = len(existing_events) > 0
|
||||
all_match = found_any and all(
|
||||
events_semantically_equal(ev, candidate_event) for ev in existing_events
|
||||
)
|
||||
|
||||
if all_match:
|
||||
logger.debug(
|
||||
"Matching NIP-91 announcement already present; skipping periodic re-announce"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
f"Re-announcing provider due to differences or absence: {candidate_event['id']}"
|
||||
)
|
||||
for relay_url in relay_urls:
|
||||
await publish_to_relay(relay_url, candidate_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
|
||||
@@ -1,268 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple Python function to publish one provider listing to a nostr relay
|
||||
according to the RIP-02 specification.
|
||||
|
||||
Based on: https://github.com/Routstr/protocol/blob/main/RIP-02.md
|
||||
Event Kind: 31338 (Routstr Provider Announcements)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import secp256k1
|
||||
import websockets
|
||||
|
||||
|
||||
def create_provider_announcement_event(
|
||||
private_key_hex: str,
|
||||
provider_name: str,
|
||||
endpoint_url: str,
|
||||
d_tag: str,
|
||||
description: str | None = None,
|
||||
contact: str | None = None,
|
||||
pricing_url: str | None = None,
|
||||
supported_models: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a RIP-02 compliant provider announcement event.
|
||||
|
||||
Args:
|
||||
private_key_hex: 32-byte hex private key for signing
|
||||
provider_name: Human readable name for the provider
|
||||
endpoint_url: Base URL for the provider's API endpoint
|
||||
d_tag: Unique identifier for this provider (required for addressable events)
|
||||
description: Optional description of the provider
|
||||
contact: Optional contact information
|
||||
pricing_url: Optional URL to pricing information
|
||||
supported_models: Optional list of supported model names
|
||||
|
||||
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 required tags according to RIP-02
|
||||
tags = [
|
||||
["d", d_tag], # Required for addressable events (kind 30000-39999)
|
||||
["endpoint", endpoint_url],
|
||||
["name", provider_name],
|
||||
]
|
||||
|
||||
# Add optional tags if provided
|
||||
if description:
|
||||
tags.append(["description", description])
|
||||
if contact:
|
||||
tags.append(["contact", contact])
|
||||
if pricing_url:
|
||||
tags.append(["pricing", pricing_url])
|
||||
if supported_models:
|
||||
for model in supported_models:
|
||||
tags.append(["model", model])
|
||||
|
||||
# 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
|
||||
31338, # Kind for RIP-02 Provider Announcements
|
||||
tags, # Tags array
|
||||
"", # Content (empty for provider announcements)
|
||||
]
|
||||
|
||||
# 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_der = private_key.ecdsa_serialize(signature)
|
||||
|
||||
# Create the final event
|
||||
event = {
|
||||
"id": event_id,
|
||||
"pubkey": public_key.hex(),
|
||||
"created_at": created_at,
|
||||
"kind": 31338,
|
||||
"tags": tags,
|
||||
"content": "",
|
||||
"sig": signature_der.hex(),
|
||||
}
|
||||
|
||||
return event
|
||||
|
||||
|
||||
async def publish_provider_to_relay(
|
||||
relay_url: str, event: dict[str, Any], timeout: int = 30
|
||||
) -> bool:
|
||||
"""
|
||||
Publish a provider announcement event to a nostr relay.
|
||||
|
||||
Args:
|
||||
relay_url: WebSocket URL of the nostr relay (e.g., "wss://relay.damus.io")
|
||||
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)
|
||||
print(f"Published 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
|
||||
print(
|
||||
f"✅ Event accepted by relay: {data[3] if len(data) > 3 else ''}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
f"❌ Event rejected by relay: {data[3] if len(data) > 3 else ''}"
|
||||
)
|
||||
return False
|
||||
elif data[0] == "NOTICE":
|
||||
print(f"📢 Relay notice: {data[1]}")
|
||||
return False
|
||||
else:
|
||||
print(f"🤔 Unexpected response: {data}")
|
||||
return False
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
print("⏰ No response from relay within timeout")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"💥 Failed to publish to {relay_url}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def publish_provider_listing(
|
||||
private_key_hex: str,
|
||||
provider_name: str,
|
||||
endpoint_url: str,
|
||||
d_tag: str,
|
||||
relay_urls: list[str] | None = None,
|
||||
description: str | None = None,
|
||||
contact: str | None = None,
|
||||
pricing_url: str | None = None,
|
||||
supported_models: list[str] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Complete function to create and publish a provider listing to nostr relays.
|
||||
|
||||
Args:
|
||||
private_key_hex: 32-byte hex private key for signing
|
||||
provider_name: Human readable name for the provider
|
||||
endpoint_url: Base URL for the provider's API endpoint
|
||||
d_tag: Unique identifier for this provider
|
||||
relay_urls: List of relay URLs to publish to (uses defaults if None)
|
||||
description: Optional description of the provider
|
||||
contact: Optional contact information
|
||||
pricing_url: Optional URL to pricing information
|
||||
supported_models: Optional list of supported model names
|
||||
|
||||
Returns:
|
||||
Dictionary mapping relay URLs to success status
|
||||
"""
|
||||
# Use default relays if none provided
|
||||
if relay_urls is None:
|
||||
relay_urls = [
|
||||
"wss://relay.nostr.band",
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.routstr.com",
|
||||
]
|
||||
|
||||
# Create the provider announcement event
|
||||
event = create_provider_announcement_event(
|
||||
private_key_hex=private_key_hex,
|
||||
provider_name=provider_name,
|
||||
endpoint_url=endpoint_url,
|
||||
d_tag=d_tag,
|
||||
description=description,
|
||||
contact=contact,
|
||||
pricing_url=pricing_url,
|
||||
supported_models=supported_models,
|
||||
)
|
||||
|
||||
print(f"📝 Created provider announcement event: {event['id']}")
|
||||
print(f"🔑 Public key: {event['pubkey']}")
|
||||
print(f"🏷️ Provider: {provider_name}")
|
||||
print(f"🌐 Endpoint: {endpoint_url}")
|
||||
print()
|
||||
|
||||
# Publish to all specified relays
|
||||
results = {}
|
||||
tasks = []
|
||||
|
||||
for relay_url in relay_urls:
|
||||
task = publish_provider_to_relay(relay_url, event)
|
||||
tasks.append((relay_url, task))
|
||||
|
||||
# Execute all publishing tasks concurrently
|
||||
for relay_url, task in tasks:
|
||||
try:
|
||||
success = await task
|
||||
results[relay_url] = success
|
||||
except Exception as e:
|
||||
print(f"💥 Failed to publish to {relay_url}: {e}")
|
||||
results[relay_url] = False
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# Example usage
|
||||
async def main() -> None:
|
||||
"""Example of how to use the provider publishing function."""
|
||||
|
||||
# Example private key (DO NOT use this in production!)
|
||||
private_key = "3185a47e3802f956ca207b46c8d6b8b5c5dbad53a5ca29816050e9b66badc33c"
|
||||
|
||||
# Example provider information
|
||||
provider_name = "My AI Provider"
|
||||
endpoint_url = "https://api.myaiprovider.com"
|
||||
d_tag = "my-ai-provider-v1" # Unique identifier
|
||||
description = "High-quality AI models with competitive pricing"
|
||||
contact = "admin@myaiprovider.com"
|
||||
pricing_url = "https://myaiprovider.com/pricing"
|
||||
supported_models = ["gpt-4o", "claude-3-sonnet", "llama-3.1-70b"]
|
||||
|
||||
# Publish to relays
|
||||
results = await publish_provider_listing(
|
||||
private_key_hex=private_key,
|
||||
provider_name=provider_name,
|
||||
endpoint_url=endpoint_url,
|
||||
d_tag=d_tag,
|
||||
description=description,
|
||||
contact=contact,
|
||||
pricing_url=pricing_url,
|
||||
supported_models=supported_models,
|
||||
)
|
||||
|
||||
# Print results
|
||||
print("\n📊 Publishing Results:")
|
||||
for relay_url, success in results.items():
|
||||
status = "✅ Success" if success else "❌ Failed"
|
||||
print(f" {relay_url}: {status}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -26,13 +26,25 @@ async def test_providers_endpoint_default_response(
|
||||
mock_events: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "event1",
|
||||
"content": "Check out this provider: http://provider1.onion",
|
||||
"pubkey": "test_pubkey1",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"created_at": 1234567890,
|
||||
"content": '{"name": "Provider 1", "about": "Test provider 1"}',
|
||||
"tags": [
|
||||
["d", "provider1"],
|
||||
["u", "http://provider1.onion"],
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "event2",
|
||||
"content": "Another provider at http://provider2.onion is good",
|
||||
"pubkey": "test_pubkey2",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"created_at": 1234567891,
|
||||
"content": '{"name": "Provider 2", "about": "Test provider 2"}',
|
||||
"tags": [
|
||||
["d", "provider2"],
|
||||
["u", "http://provider2.onion"],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -60,10 +72,11 @@ async def test_providers_endpoint_default_response(
|
||||
assert "providers" in data
|
||||
assert isinstance(data["providers"], list)
|
||||
|
||||
# In default format, should return list of provider URLs (strings)
|
||||
# In default format, should return list of provider objects
|
||||
for provider in data["providers"]:
|
||||
assert isinstance(provider, str)
|
||||
assert provider.endswith(".onion")
|
||||
assert isinstance(provider, dict)
|
||||
assert "endpoint_url" in provider
|
||||
assert provider["endpoint_url"].endswith(".onion")
|
||||
|
||||
# Verify no database state changes
|
||||
diff = await db_snapshot.diff()
|
||||
@@ -86,8 +99,14 @@ async def test_providers_endpoint_with_include_json(
|
||||
mock_events: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "event1",
|
||||
"content": "Provider info: http://test-provider.onion",
|
||||
"pubkey": "test_pubkey",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"created_at": 1234567890,
|
||||
"content": '{"name": "Test Provider", "about": "A test provider"}',
|
||||
"tags": [
|
||||
["d", "test-provider"],
|
||||
["u", "http://test-provider.onion"],
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -117,15 +136,20 @@ async def test_providers_endpoint_with_include_json(
|
||||
assert "providers" in data
|
||||
assert isinstance(data["providers"], list)
|
||||
|
||||
# With include_json=true, should return list of dictionaries
|
||||
for provider in data["providers"]:
|
||||
assert isinstance(provider, dict)
|
||||
# Each provider should be in format {url: json_data}
|
||||
assert len(provider) == 1
|
||||
url = list(provider.keys())[0]
|
||||
json_data = provider[url]
|
||||
assert url.endswith(".onion")
|
||||
assert isinstance(json_data, dict)
|
||||
# With include_json=true, should return list of dictionaries with provider and health info
|
||||
for provider_data in data["providers"]:
|
||||
assert isinstance(provider_data, dict)
|
||||
# Each provider should have 'provider' and 'health' keys
|
||||
assert "provider" in provider_data
|
||||
assert "health" in provider_data
|
||||
|
||||
provider_info = provider_data["provider"]
|
||||
assert "endpoint_url" in provider_info
|
||||
assert provider_info["endpoint_url"].endswith(".onion")
|
||||
|
||||
health_info = provider_data["health"]
|
||||
assert isinstance(health_info, dict)
|
||||
assert "status_code" in health_info
|
||||
|
||||
# Verify no database state changes
|
||||
diff = await db_snapshot.diff()
|
||||
@@ -141,20 +165,18 @@ async def test_providers_data_structure_validation(
|
||||
) -> None:
|
||||
"""Test provider data structure contains expected fields"""
|
||||
|
||||
# Mock RIP-02 provider announcement event
|
||||
# Mock NIP-91 provider announcement event
|
||||
mock_events: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "event1",
|
||||
"pubkey": "test_pubkey",
|
||||
"created_at": 1234567890,
|
||||
"content": "Comprehensive provider announcement",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"content": '{"name": "Comprehensive Provider", "about": "A comprehensive AI provider"}',
|
||||
"tags": [
|
||||
["d", "provider-123"],
|
||||
["endpoint", "https://api.provider.example/v1"],
|
||||
["name", "Comprehensive Provider"],
|
||||
["description", "A comprehensive AI provider"],
|
||||
["model", "gpt-3.5-turbo"],
|
||||
["model", "gpt-4"],
|
||||
["u", "https://api.provider.example/v1"],
|
||||
["models", "gpt-3.5-turbo", "gpt-4"],
|
||||
],
|
||||
}
|
||||
]
|
||||
@@ -190,7 +212,7 @@ async def test_providers_data_structure_validation(
|
||||
assert "health" in provider_data
|
||||
|
||||
provider_info = provider_data["provider"]
|
||||
# Expected fields from RIP-02 parser
|
||||
# Expected fields from NIP-91 parser
|
||||
expected_fields = ["id", "name", "endpoint_url", "supported_models"]
|
||||
for field in expected_fields:
|
||||
assert field in provider_info
|
||||
@@ -239,23 +261,23 @@ async def test_providers_endpoint_offline_providers(
|
||||
{
|
||||
"id": "event1",
|
||||
"pubkey": "healthy_provider_pubkey",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"created_at": 1234567890,
|
||||
"content": "Healthy provider announcement",
|
||||
"content": '{"name": "Healthy Provider", "about": "Healthy provider announcement"}',
|
||||
"tags": [
|
||||
["d", "healthy-provider"],
|
||||
["endpoint", "http://healthy-provider.onion"],
|
||||
["name", "Healthy Provider"],
|
||||
["u", "http://healthy-provider.onion"],
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "event2",
|
||||
"pubkey": "offline_provider_pubkey",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"created_at": 1234567891,
|
||||
"content": "Offline provider announcement",
|
||||
"content": '{"name": "Offline Provider", "about": "Offline provider announcement"}',
|
||||
"tags": [
|
||||
["d", "offline-provider"],
|
||||
["endpoint", "http://offline-provider.onion"],
|
||||
["name", "Offline Provider"],
|
||||
["u", "http://offline-provider.onion"],
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -323,23 +345,23 @@ async def test_providers_endpoint_duplicate_urls(
|
||||
{
|
||||
"id": "event1",
|
||||
"pubkey": "provider_pubkey",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"created_at": 1234567890,
|
||||
"content": "Provider announcement",
|
||||
"content": '{"name": "Provider", "about": "Provider announcement"}',
|
||||
"tags": [
|
||||
["d", "provider-1"],
|
||||
["endpoint", "http://provider.onion"],
|
||||
["name", "Provider"],
|
||||
["u", "http://provider.onion"],
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "event2",
|
||||
"pubkey": "other_provider_pubkey",
|
||||
"kind": 38421, # NIP-91 event kind
|
||||
"created_at": 1234567892,
|
||||
"content": "Different provider announcement",
|
||||
"content": '{"name": "Other Provider", "about": "Different provider announcement"}',
|
||||
"tags": [
|
||||
["d", "other-provider"],
|
||||
["endpoint", "http://other-provider.onion"],
|
||||
["name", "Other Provider"],
|
||||
["u", "http://other-provider.onion"],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user