Compare commits

..
Author SHA1 Message Date
Cursor Agentanddb2002dominic ce38a31536 feat: Add initial setup onboarding component
Co-authored-by: db2002dominic <db2002dominic@gmail.com>
2025-11-15 23:44:03 +00:00
shroominicandGitHub 14ae4ecce3 v0.2.0c
fix calculate_usd_max_costs
2025-11-14 20:27:25 +08:00
Shroominic a8b6d4866f v0.2.0c 2025-11-14 20:15:36 +08:00
Shroominic 924f93c18d fiiixXXXXXX 2025-11-14 19:42:20 +08:00
Shroominic 7c2ac805c8 fix calculate_usd_max_costs 2025-11-14 17:09:50 +08:00
shroominicandGitHub bb9b632ceb Merge pull request #223 from Routstr/v0.2.0b
V0.2.0b
2025-11-14 16:08:46 +08:00
Shroominic 50c43e9b07 fix vision prompt discounted max_cost calculation 2025-11-13 16:47:26 +08:00
Shroominic a4c092d8dc rm fetch_models 2025-11-13 16:09:35 +08:00
Shroominic 94e7b2b4d2 Fix settings override bug: allow False and 0 from database
Fixes #217 - Database values of False and 0 are now properly respected
instead of being ignored. Removed 'and v' check that incorrectly treated
these legitimate config values as 'empty'. Now only truly empty values
(None, empty string, empty list, empty dict) are ignored in favor of env.
2025-11-13 16:03:56 +08:00
9qeklajcandGitHub 637f3459c5 Merge pull request #222 from Routstr/update-deps
Update ui deps
2025-11-12 23:42:06 +01:00
Shroominic 9a52e30470 get api key link 2025-11-11 17:31:19 +08:00
Shroominic 30d62bf65c fix linting 2025-11-11 17:24:21 +08:00
Shroominic 29b088c035 bump v0.2.0b 2025-11-11 17:21:52 +08:00
Shroominic d16b0d5190 fix groq +xai model fetching 2025-11-11 17:19:04 +08:00
Shroominic 320cfe82fd auto populate providers from available classes 2025-11-11 16:10:42 +08:00
31 changed files with 1315 additions and 280 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "routstr"
version = "0.2.0"
version = "0.2.0c"
description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
@@ -19,6 +19,7 @@ dependencies = [
"websockets>=12.0",
"nostr>=0.0.2",
"mdurl==0.1.2",
"pillow>=10",
]
[dependency-groups]
+1 -1
View File
@@ -193,7 +193,7 @@ def create_model_mappings(
Tuple of (model_instances, provider_map, unique_models)
"""
from .payment.models import _row_to_model
from .upstream import resolve_model_alias
from .upstream.helpers import resolve_model_alias
model_instances: dict[str, "Model"] = {}
provider_map: dict[str, "BaseUpstreamProvider"] = {}
+4 -40
View File
@@ -2739,45 +2739,9 @@ async def delete_upstream_provider(provider_id: int) -> dict[str, object]:
@admin_router.get("/api/provider-types", dependencies=[Depends(require_admin_api)])
async def get_provider_types() -> list[dict[str, object]]:
"""Get metadata about available provider types including default URLs and whether they're fixed."""
provider_types = [
{
"id": "openrouter",
"name": "OpenRouter",
"default_base_url": "https://openrouter.ai/api/v1",
"fixed_base_url": True,
},
{
"id": "openai",
"name": "OpenAI",
"default_base_url": "https://api.openai.com/v1",
"fixed_base_url": True,
},
{
"id": "anthropic",
"name": "Anthropic",
"default_base_url": "https://api.anthropic.com/v1",
"fixed_base_url": True,
},
{
"id": "azure",
"name": "Azure OpenAI",
"default_base_url": "",
"fixed_base_url": False,
},
{
"id": "ollama",
"name": "Ollama",
"default_base_url": "http://localhost:11434",
"fixed_base_url": False,
},
{
"id": "generic",
"name": "Generic",
"default_base_url": "",
"fixed_base_url": False,
},
]
return provider_types
from ..upstream import upstream_provider_classes
return [cls.get_provider_metadata() for cls in upstream_provider_classes]
@admin_router.get(
@@ -2785,7 +2749,7 @@ async def get_provider_types() -> list[dict[str, object]]:
dependencies=[Depends(require_admin_api)],
)
async def get_provider_models(provider_id: int) -> dict[str, object]:
from ..upstream import _instantiate_provider
from ..upstream.helpers import _instantiate_provider
async with create_session() as session:
provider = await session.get(UpstreamProviderRow, provider_id)
+3 -3
View File
@@ -34,9 +34,9 @@ setup_logging()
logger = get_logger(__name__)
if os.getenv("VERSION_SUFFIX") is not None:
__version__ = f"0.2.0-{os.getenv('VERSION_SUFFIX')}"
__version__ = f"0.2.0c-{os.getenv('VERSION_SUFFIX')}"
else:
__version__ = "0.2.0"
__version__ = "0.2.0c"
@asynccontextmanager
@@ -78,7 +78,7 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
from ..payment.price import _update_prices
from ..proxy import get_upstreams
from ..upstream import refresh_upstreams_models_periodically
from ..upstream.helpers import refresh_upstreams_models_periodically
await _update_prices()
await initialize_upstreams()
+1 -1
View File
@@ -235,7 +235,7 @@ class SettingsService:
merged_dict: dict[str, Any] = dict(env_resolved.dict())
merged_dict.update(
{k: v for k, v in db_json.items() if v not in (None, "", []) and v}
{k: v for k, v in db_json.items() if v not in (None, "", [], {})}
)
# Ensure primary_mint is consistent with cashu_mints if not explicitly set
+183 -7
View File
@@ -1,9 +1,13 @@
import base64
import json
import math
from io import BytesIO
from typing import Any
import httpx
from fastapi import HTTPException, Response
from fastapi.requests import Request
from PIL import Image
from sqlmodel.ext.asyncio.session import AsyncSession
from ..core import get_logger
@@ -172,13 +176,23 @@ async def calculate_discounted_max_cost(
if messages := body.get("messages"):
prompt_tokens = estimate_tokens(messages)
image_tokens = await estimate_image_tokens_in_messages(messages)
if image_tokens > 0:
logger.debug(
"Found images in request",
extra={
"model": model,
"image_tokens": image_tokens,
},
)
prompt_tokens += image_tokens
estimated_prompt_delta_sats = (
max_prompt_allowed_sats - prompt_tokens * model_pricing.prompt
)
if estimated_prompt_delta_sats >= 0:
if estimated_prompt_delta_sats > 0:
adjusted = adjusted - math.floor(estimated_prompt_delta_sats * 1000)
else:
adjusted = adjusted + math.ceil(-estimated_prompt_delta_sats * 1000)
max_tokens_raw = body.get("max_tokens", None)
if max_tokens_raw is not None:
@@ -193,10 +207,8 @@ async def calculate_discounted_max_cost(
estimated_completion_delta_sats = (
max_completion_allowed_sats - max_tokens_int * model_pricing.completion
)
if estimated_completion_delta_sats >= 0:
if estimated_completion_delta_sats > 0:
adjusted = adjusted - math.floor(estimated_completion_delta_sats * 1000)
else:
adjusted = adjusted + math.ceil(-estimated_completion_delta_sats * 1000)
logger.debug(
"Discounted max cost computed",
@@ -212,7 +224,171 @@ async def calculate_discounted_max_cost(
def estimate_tokens(messages: list) -> int:
return len(str(messages)) // 3
"""Estimate tokens for text content, excluding image_url fields."""
total = 0
for msg in messages:
if isinstance(msg, dict):
content = msg.get("content")
if isinstance(content, str):
total += len(content)
elif isinstance(content, list):
total += sum(
len(item.get("text", ""))
for item in content
if isinstance(item, dict) and item.get("type") == "text"
)
return total // 3
def _get_image_dimensions(image_data: bytes) -> tuple[int, int]:
"""Extract image dimensions from image bytes."""
try:
img = Image.open(BytesIO(image_data))
return img.size
except Exception as e:
logger.warning(
"Failed to get image dimensions, using default",
extra={"error": str(e)},
)
return (512, 512)
async def _fetch_image_from_url(url: str) -> bytes | None:
"""Fetch image from URL."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(url)
response.raise_for_status()
return response.content
except Exception as e:
logger.warning(
"Failed to fetch image from URL",
extra={"error": str(e), "url": url[:100]},
)
return None
def _calculate_image_tokens(width: int, height: int, detail: str = "auto") -> int:
"""Calculate image tokens based on OpenAI's vision pricing.
For low detail: 85 tokens
For high detail/auto: 85 base tokens + 170 tokens per 512px tile
"""
if detail == "low":
return 85
if width > 2048 or height > 2048:
aspect_ratio = width / height
if width > height:
width = 2048
height = int(width / aspect_ratio)
else:
height = 2048
width = int(height * aspect_ratio)
if width > 768 or height > 768:
aspect_ratio = width / height
if width > height:
width = 768
height = int(width / aspect_ratio)
else:
height = 768
width = int(height * aspect_ratio)
tiles_width = (width + 511) // 512
tiles_height = (height + 511) // 512
num_tiles = tiles_width * tiles_height
return 85 + (170 * num_tiles)
async def estimate_image_tokens_in_messages(messages: list) -> int:
"""Estimate total tokens for all images in messages.
Supports both base64 encoded images and image URLs.
"""
total_image_tokens = 0
for message in messages:
if not isinstance(message, dict):
continue
content = message.get("content")
if not content:
continue
if isinstance(content, str):
continue
if not isinstance(content, list):
continue
for content_item in content:
if not isinstance(content_item, dict):
continue
content_type = content_item.get("type")
if content_type not in ("image_url", "input_image"):
continue
image_url_data = content_item.get("image_url")
if not image_url_data:
continue
if isinstance(image_url_data, str):
url = image_url_data
detail = "auto"
elif isinstance(image_url_data, dict):
url = image_url_data.get("url", "")
detail = image_url_data.get("detail", "auto")
else:
continue
if not url:
continue
if url.startswith("data:image/"):
try:
header, base64_data = url.split(",", 1)
image_bytes = base64.b64decode(base64_data)
width, height = _get_image_dimensions(image_bytes)
tokens = _calculate_image_tokens(width, height, detail)
total_image_tokens += tokens
logger.debug(
"Calculated tokens for base64 image",
extra={
"width": width,
"height": height,
"detail": detail,
"tokens": tokens,
},
)
except Exception as e:
logger.warning(
"Failed to process base64 image",
extra={"error": str(e)},
)
total_image_tokens += 85
else:
image_bytes_or_none = await _fetch_image_from_url(url)
if image_bytes_or_none:
width, height = _get_image_dimensions(image_bytes_or_none)
tokens = _calculate_image_tokens(width, height, detail)
total_image_tokens += tokens
logger.debug(
"Calculated tokens for URL image",
extra={
"url": url[:100],
"width": width,
"height": height,
"detail": detail,
"tokens": tokens,
},
)
else:
total_image_tokens += 85
return total_image_tokens
def create_error_response(
+13 -7
View File
@@ -340,28 +340,34 @@ def _calculate_usd_max_costs(model: Model) -> tuple[float, float, float]:
if (cl := model.top_provider.context_length) and (
mct := model.top_provider.max_completion_tokens
):
if cl <= mct:
return (
cl * prompt_price,
cl * completion_price,
cl * max(completion_price, prompt_price),
)
return (
(cl - mct) * prompt_price,
cl * prompt_price,
mct * completion_price,
(cl - mct) * prompt_price + mct * completion_price,
)
elif cl := model.top_provider.context_length:
return (
cl * 0.8 * prompt_price,
cl * 0.2 * completion_price,
cl * prompt_price,
cl * completion_price,
cl * max(completion_price, prompt_price),
)
elif mct := model.top_provider.max_completion_tokens:
return (
mct * 4 * prompt_price,
mct * prompt_price,
mct * completion_price,
mct * completion_price,
mct * 5 * prompt_price,
)
elif model.context_length:
return (
model.context_length * 0.8 * prompt_price,
model.context_length * 0.2 * completion_price,
model.context_length * prompt_price,
model.context_length * completion_price,
model.context_length * max(completion_price, prompt_price),
)
p = prompt_price * 1_000_000
+2 -1
View File
@@ -23,7 +23,8 @@ from .payment.helpers import (
get_max_cost_for_model,
)
from .payment.models import Model
from .upstream import BaseUpstreamProvider, init_upstreams
from .upstream import BaseUpstreamProvider
from .upstream.helpers import init_upstreams
logger = get_logger(__name__)
proxy_router = APIRouter()
+20 -23
View File
@@ -1,34 +1,31 @@
from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .fireworks import FireworksUpstreamProvider
from .generic import GenericUpstreamProvider
from .helpers import (
_instantiate_provider,
_seed_providers_from_settings,
get_all_models_with_overrides,
init_upstreams,
refresh_upstreams_models_periodically,
resolve_model_alias,
)
from .groq import GroqUpstreamProvider
from .ollama import OllamaUpstreamProvider
from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
from .perplexity import PerplexityUpstreamProvider
from .xai import XAIUpstreamProvider
upstream_provider_classes: list[type[BaseUpstreamProvider]] = [
AnthropicUpstreamProvider,
AzureUpstreamProvider,
FireworksUpstreamProvider,
GenericUpstreamProvider,
GroqUpstreamProvider,
OllamaUpstreamProvider,
OpenAIUpstreamProvider,
OpenRouterUpstreamProvider,
PerplexityUpstreamProvider,
XAIUpstreamProvider,
]
"""List of all upstream classes"""
__all__ = [
# upstreams
"AnthropicUpstreamProvider",
"AzureUpstreamProvider",
"BaseUpstreamProvider",
"GenericUpstreamProvider",
"OllamaUpstreamProvider",
"OpenAIUpstreamProvider",
"OpenRouterUpstreamProvider",
# helpers
"resolve_model_alias",
"get_all_models_with_overrides",
"get_model_with_override",
"refresh_upstreams_models_periodically",
"init_upstreams",
"_seed_providers_from_settings",
"_instantiate_provider",
*[cls.__name__ for cls in upstream_provider_classes],
"upstream_provider_classes",
]
+29 -2
View File
@@ -1,18 +1,45 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class AnthropicUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Anthropic API."""
provider_type = "anthropic"
default_base_url = "https://api.anthropic.com/v1"
platform_url = "https://console.anthropic.com/settings/keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
self.upstream_name = "anthropic"
super().__init__(
base_url="https://api.anthropic.com/v1",
base_url=self.default_base_url,
api_key=api_key,
provider_fee=provider_fee,
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "AnthropicUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Anthropic",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
"""Strip 'anthropic/' prefix for Anthropic API compatibility and transform model names."""
if model_id.startswith("anthropic/"):
+31 -1
View File
@@ -1,11 +1,18 @@
from typing import Mapping
from typing import TYPE_CHECKING, Mapping
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class AzureUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Azure OpenAI Service."""
provider_type = "azure"
default_base_url = None
platform_url = "https://portal.azure.com/"
def __init__(
self,
base_url: str,
@@ -28,6 +35,29 @@ class AzureUpstreamProvider(BaseUpstreamProvider):
)
self.api_version = api_version
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "AzureUpstreamProvider | None":
if not provider_row.api_version:
return None
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
api_version=provider_row.api_version,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Azure OpenAI",
"default_base_url": "",
"fixed_base_url": False,
"platform_url": cls.platform_url,
}
def prepare_params(
self, path: str, query_params: Mapping[str, str] | None
) -> Mapping[str, str]:
+137 -8
View File
@@ -1,10 +1,11 @@
from __future__ import annotations
import asyncio
import json
import re
import traceback
from collections.abc import AsyncGenerator
from typing import Mapping
from typing import TYPE_CHECKING, Mapping
import httpx
from fastapi import BackgroundTasks, HTTPException, Request
@@ -13,6 +14,10 @@ from fastapi.responses import Response, StreamingResponse
from ..auth import adjust_payment_for_tokens
from ..core import get_logger
from ..core.db import ApiKey, AsyncSession, create_session
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..payment.cost_caculation import (
CostData,
CostDataError,
@@ -35,9 +40,12 @@ logger = get_logger(__name__)
class BaseUpstreamProvider:
"""Provider for forwarding requests to an upstream AI service API."""
provider_type: str = "base"
default_base_url: str | None = None
platform_url: str | None = None
base_url: str
api_key: str
upstream_name: str | None = None
provider_fee: float = 1.05
_models_cache: list[Model] = []
_models_by_id: dict[str, Model] = {}
@@ -56,6 +64,39 @@ class BaseUpstreamProvider:
self._models_cache = []
self._models_by_id = {}
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "BaseUpstreamProvider | None":
"""Factory method to instantiate provider from database row.
Args:
provider_row: Database row containing provider configuration
Returns:
Instantiated provider or None if instantiation fails
"""
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
"""Get metadata about this provider type for API responses.
Returns:
Dict with provider type metadata including id, name, default_base_url, fixed_base_url, platform_url
"""
return {
"id": cls.provider_type,
"name": cls.provider_type.title(),
"default_base_url": cls.default_base_url or "",
"fixed_base_url": bool(cls.default_base_url),
"platform_url": cls.platform_url,
}
def prepare_headers(self, request_headers: dict) -> dict:
"""Prepare headers for upstream request by removing proxy-specific headers and adding authentication.
@@ -162,7 +203,7 @@ class BaseUpstreamProvider:
extra={
"original": original_model,
"transformed": transformed_model,
"provider": self.upstream_name or self.base_url,
"provider": self.provider_type or self.base_url,
},
)
return json.dumps(data).encode()
@@ -171,7 +212,7 @@ class BaseUpstreamProvider:
"Could not transform request body",
extra={
"error": str(e),
"provider": self.upstream_name or self.base_url,
"provider": self.provider_type or self.base_url,
},
)
@@ -1657,8 +1698,96 @@ class BaseUpstreamProvider:
Returns:
List of Model objects with pricing
"""
logger.debug(f"Fetching models for {self.upstream_name or self.base_url}")
return []
logger.debug(f"Fetching models for {self.provider_type or self.base_url}")
try:
or_models, provider_models_response = await asyncio.gather(
self._fetch_openrouter_models(),
self._fetch_provider_models(),
)
provider_model_ids = self._parse_model_ids(provider_models_response)
found_models = []
not_found_models = []
for model_id in provider_model_ids:
or_model = self._match_model(model_id, or_models)
if or_model:
try:
model = Model(**or_model) # type: ignore
found_models.append(model)
except Exception as e:
logger.warning(
f"Failed to parse model {model_id}",
extra={"error": str(e), "error_type": type(e).__name__},
)
else:
not_found_models.append(model_id)
logger.info(
"Fetched models for provider",
extra={
"provider": self.provider_type or self.base_url,
"found_count": len(found_models),
"not_found_count": len(not_found_models),
},
)
if not_found_models:
logger.debug(
"Models not found in OpenRouter",
extra={"not_found_models": not_found_models},
)
return found_models
except Exception as e:
logger.error(
f"Error fetching models for {self.provider_type or self.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
return []
async def _fetch_openrouter_models(self) -> list[dict]:
"""Fetch models from OpenRouter API."""
url = "https://openrouter.ai/api/v1/models"
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url)
response.raise_for_status()
models = response.json()
return [
model
for model in models.get("data", [])
if ":free" not in model.get("id", "").lower()
]
async def _fetch_provider_models(self) -> dict:
"""Fetch models from provider's API."""
url = f"{self.base_url.rstrip('/')}/models"
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else None
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
return response.json()
def _parse_model_ids(self, response: dict) -> list[str]:
"""Parse model IDs from provider response."""
return [model.get("id") for model in response.get("data", []) if "id" in model]
def _match_model(self, model_id: str, or_models: list[dict]) -> dict | None:
"""Match provider model ID with OpenRouter model."""
return next(
(
model
for model in or_models
if (model.get("id") == model_id)
or (model.get("id", "").split("/")[-1] == model_id)
or (model.get("canonical_slug") == model_id)
or (model.get("canonical_slug", "").split("/")[-1] == model_id)
),
None,
)
async def refresh_models_cache(self) -> None:
"""Refresh the in-memory models cache from upstream API."""
@@ -1676,12 +1805,12 @@ class BaseUpstreamProvider:
self._models_by_id = {m.id: m for m in self._models_cache}
logger.info(
f"Refreshed models cache for {self.upstream_name or self.base_url}",
f"Refreshed models cache for {self.provider_type or self.base_url}",
extra={"model_count": len(models)},
)
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.upstream_name or self.base_url}",
f"Failed to refresh models cache for {self.provider_type or self.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
+28 -10
View File
@@ -1,24 +1,42 @@
from ..payment.models import Model, async_fetch_openrouter_models
from typing import TYPE_CHECKING
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class FireworksUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Fireworks.ai API."""
upstream_name = "fireworks"
base_url = "https://api.fireworks.ai/inference/v1"
provider_type = "fireworks"
default_base_url = "https://api.fireworks.ai/inference/v1"
platform_url = "https://app.fireworks.ai/settings/users/api-keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url, api_key=api_key, provider_fee=provider_fee
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "FireworksUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Fireworks",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
"""Strip 'fireworks/' prefix for Fireworks API compatibility."""
return model_id.removeprefix("fireworks/")
async def fetch_models(self) -> list[Model]:
"""Fetch Fireworks models from OpenRouter API filtered by fireworks source."""
models_data = await async_fetch_openrouter_models(source_filter="fireworks")
return [Model(**model) for model in models_data] # type: ignore
return model_id.split("/")[-1]
+25
View File
@@ -7,6 +7,7 @@ import httpx
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
@@ -17,6 +18,10 @@ logger = get_logger(__name__)
class GenericUpstreamProvider(BaseUpstreamProvider):
"""Generic upstream provider that can fetch models from any OpenAI-compatible API."""
provider_type = "generic"
default_base_url = "http://localhost:8888"
platform_url = None
def __init__(
self,
base_url: str,
@@ -39,6 +44,26 @@ class GenericUpstreamProvider(BaseUpstreamProvider):
provider_fee=provider_fee,
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "GenericUpstreamProvider":
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Generic",
"default_base_url": cls.default_base_url,
"fixed_base_url": False,
"platform_url": cls.platform_url,
}
async def fetch_models(self) -> list[Model]:
"""Fetch models from upstream API using /models endpoint."""
from ..payment.models import Architecture, Model, Pricing, TopProvider
+25 -9
View File
@@ -1,24 +1,40 @@
from ..payment.models import Model, async_fetch_openrouter_models
from typing import TYPE_CHECKING
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class GroqUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Groq API."""
upstream_name = "groq"
base_url = "https://api.groq.com/openai/v1"
provider_type = "groq"
default_base_url = "https://api.groq.com/openai/v1"
platform_url = "https://console.groq.com/keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url, api_key=api_key, provider_fee=provider_fee
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
)
@classmethod
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "GroqUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Groq",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
"""Strip 'groq/' prefix for Groq API compatibility."""
return model_id.removeprefix("groq/")
async def fetch_models(self) -> list[Model]:
"""Fetch Groq models from OpenRouter API filtered by groq source."""
models_data = await async_fetch_openrouter_models(source_filter="groq")
return [Model(**model) for model in models_data] # type: ignore
+65 -131
View File
@@ -11,13 +11,7 @@ if TYPE_CHECKING:
from ..core import get_logger
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
from ..payment.models import Model
from .anthropic import AnthropicUpstreamProvider
from .azure import AzureUpstreamProvider
from .base import BaseUpstreamProvider
from .generic import GenericUpstreamProvider
from .ollama import OllamaUpstreamProvider
from .openai import OpenAIUpstreamProvider
from .openrouter import OpenRouterUpstreamProvider
logger = get_logger(__name__)
@@ -148,7 +142,7 @@ async def refresh_upstreams_models_periodically(
await upstream.refresh_models_cache()
except Exception as e:
logger.error(
f"Error refreshing models for {upstream.upstream_name or upstream.base_url}",
f"Error refreshing models for {upstream.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
except asyncio.CancelledError:
@@ -220,61 +214,47 @@ async def _seed_providers_from_settings(
"""
from sqlmodel import select
from ..core.settings import settings
from . import upstream_provider_classes
providers_to_add: list[UpstreamProviderRow] = []
seeded_base_urls: set[str] = set()
openai_api_key = os.environ.get("OPENAI_API_KEY")
if openai_api_key:
base_url = "https://api.openai.com/v1"
result = await session.exec(
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
)
if not result.first():
providers_to_add.append(
UpstreamProviderRow(
provider_type="openai",
base_url=base_url,
api_key=openai_api_key,
enabled=True,
)
)
seeded_base_urls.add(base_url)
provider_classes_by_type = {
cls.provider_type: cls
for cls in upstream_provider_classes # type: ignore[attr-defined]
}
anthropic_api_key = os.environ.get("ANTHROPIC_API_KEY")
if anthropic_api_key:
base_url = "https://api.anthropic.com/v1"
result = await session.exec(
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
)
if not result.first():
providers_to_add.append(
UpstreamProviderRow(
provider_type="anthropic",
base_url=base_url,
api_key=anthropic_api_key,
enabled=True,
)
)
seeded_base_urls.add(base_url)
env_mappings: list[tuple[str, str, str | None, str | None]] = [
("OPENAI_API_KEY", "openai", None, None),
("ANTHROPIC_API_KEY", "anthropic", None, None),
("OPENROUTER_API_KEY", "openrouter", None, None),
("GROQ_API_KEY", "groq", None, None),
("PERPLEXITY_API_KEY", "perplexity", None, None),
("FIREWORKS_API_KEY", "fireworks", None, None),
("XAI_API_KEY", "xai", None, None),
]
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY")
if openrouter_api_key:
base_url = "https://openrouter.ai/api/v1"
result = await session.exec(
select(UpstreamProviderRow).where(UpstreamProviderRow.base_url == base_url)
)
if not result.first():
providers_to_add.append(
UpstreamProviderRow(
provider_type="openrouter",
base_url=base_url,
api_key=openrouter_api_key,
enabled=True,
for env_key, provider_type, _, _ in env_mappings:
api_key = os.environ.get(env_key)
if api_key and provider_type in provider_classes_by_type:
provider_class = provider_classes_by_type[provider_type]
if provider_class.default_base_url: # type: ignore[attr-defined]
base_url = provider_class.default_base_url # type: ignore[attr-defined]
result = await session.exec(
select(UpstreamProviderRow).where(
UpstreamProviderRow.base_url == base_url
)
)
)
seeded_base_urls.add(base_url)
if not result.first():
providers_to_add.append(
UpstreamProviderRow(
provider_type=provider_type,
base_url=base_url,
api_key=api_key,
enabled=True,
)
)
seeded_base_urls.add(base_url)
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
if ollama_base_url:
@@ -323,48 +303,20 @@ async def _seed_providers_from_settings(
)
)
if not result.first():
if "api.openai.com" in base_url.lower():
providers_to_add.append(
UpstreamProviderRow(
provider_type="openai",
base_url=base_url,
api_key=settings.upstream_api_key,
enabled=True,
)
)
elif "api.anthropic.com" in base_url.lower():
providers_to_add.append(
UpstreamProviderRow(
provider_type="anthropic",
base_url=base_url,
api_key=settings.upstream_api_key,
enabled=True,
)
)
elif "openrouter.ai/api/v1" in base_url.lower():
providers_to_add.append(
UpstreamProviderRow(
provider_type="openrouter",
base_url=base_url,
api_key=settings.upstream_api_key,
enabled=True,
)
)
else:
providers_to_add.append(
UpstreamProviderRow(
provider_type="custom",
base_url=base_url,
api_key=settings.upstream_api_key,
enabled=True,
)
providers_to_add.append(
UpstreamProviderRow(
provider_type="custom",
base_url=base_url,
api_key=settings.upstream_api_key,
enabled=True,
)
)
seeded_base_urls.add(base_url)
for provider in providers_to_add:
session.add(provider)
logger.info(
f"Seeding {provider.provider_type} provider",
f"Seeding {provider.provider_type} provider", # type: ignore[str-format]
extra={"base_url": provider.base_url},
)
@@ -380,53 +332,35 @@ def _instantiate_provider(
Returns:
Instantiated provider or None if provider type is unknown
"""
from . import upstream_provider_classes
try:
if provider_row.provider_type == "openai":
return OpenAIUpstreamProvider(
provider_row.api_key, provider_row.provider_fee
)
elif provider_row.provider_type == "anthropic":
return AnthropicUpstreamProvider(
provider_row.api_key, provider_row.provider_fee
)
elif provider_row.provider_type == "azure":
if not provider_row.api_version:
provider_classes_by_type = {
cls.provider_type: cls
for cls in upstream_provider_classes # type: ignore[attr-defined]
}
provider_class = provider_classes_by_type.get(provider_row.provider_type)
if provider_class:
provider = provider_class.from_db_row(provider_row) # type: ignore[attr-defined]
if provider is None:
logger.error(
"Azure provider missing api_version",
f"Failed to instantiate {provider_row.provider_type} provider",
extra={"base_url": provider_row.base_url},
)
return None
return AzureUpstreamProvider(
provider_row.base_url,
provider_row.api_key,
provider_row.api_version,
provider_row.provider_fee,
)
elif provider_row.provider_type == "openrouter":
return OpenRouterUpstreamProvider(
provider_row.api_key, provider_row.provider_fee
)
elif provider_row.provider_type == "ollama":
return OllamaUpstreamProvider(
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
)
elif provider_row.provider_type == "generic":
return GenericUpstreamProvider(
provider_row.base_url,
provider_row.api_key,
provider_row.provider_fee,
provider_row.provider_type,
)
elif provider_row.provider_type == "custom":
return provider
if provider_row.provider_type == "custom":
return BaseUpstreamProvider(
provider_row.base_url, provider_row.api_key, provider_row.provider_fee
)
else:
logger.error(
f"Unknown provider type: {provider_row.provider_type}",
extra={"base_url": provider_row.base_url},
)
return None
logger.error(
f"Unknown provider type: {provider_row.provider_type}",
extra={"base_url": provider_row.base_url},
)
return None
except Exception as e:
logger.error(
f"Failed to instantiate provider: {e}",
+27 -4
View File
@@ -9,7 +9,7 @@ from fastapi.responses import Response, StreamingResponse
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import ApiKey, AsyncSession
from ..core.db import ApiKey, AsyncSession, UpstreamProviderRow
from ..payment.models import Model
from ..core.logging import get_logger
@@ -20,6 +20,10 @@ logger = get_logger(__name__)
class OllamaUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for Ollama API."""
provider_type = "ollama"
default_base_url = "http://localhost:11434"
platform_url = None
def __init__(
self,
base_url: str = "http://localhost:11434",
@@ -33,13 +37,32 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
api_key: Optional API key (Ollama typically doesn't require one)
provider_fee: Provider fee multiplier (default 1.01 for 1% fee)
"""
self.upstream_name = "ollama"
super().__init__(
base_url=base_url,
api_key=api_key,
provider_fee=provider_fee,
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "OllamaUpstreamProvider":
return cls(
base_url=provider_row.base_url,
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Ollama",
"default_base_url": cls.default_base_url,
"fixed_base_url": False,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
"""Strip 'ollama/' prefix for Ollama API compatibility."""
return model_id.removeprefix("ollama/")
@@ -192,12 +215,12 @@ class OllamaUpstreamProvider(BaseUpstreamProvider):
self._models_by_id = {m.id: m for m in self._models_cache}
logger.info(
f"Refreshed models cache for {self.upstream_name or self.base_url}",
f"Refreshed models cache for {self.base_url}",
extra={"model_count": len(models)},
)
except Exception as e:
logger.error(
f"Failed to refresh models cache for {self.upstream_name or self.base_url}",
f"Failed to refresh models cache for {self.base_url}",
extra={"error": str(e), "error_type": type(e).__name__},
)
+29 -4
View File
@@ -1,18 +1,43 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class OpenAIUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenAI API."""
provider_type = "openai"
default_base_url = "https://api.openai.com/v1"
platform_url = "https://platform.openai.com/api-keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
self.upstream_name = "openai"
super().__init__(
base_url="https://api.openai.com/v1",
api_key=api_key,
provider_fee=provider_fee,
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "OpenAIUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "OpenAI",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
"""Strip 'openai/' prefix for OpenAI API compatibility."""
return model_id.removeprefix("openai/")
+29 -4
View File
@@ -1,10 +1,19 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class OpenRouterUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenRouter API."""
provider_type = "openrouter"
default_base_url = "https://openrouter.ai/api/v1"
platform_url = "https://openrouter.ai/settings/keys"
def __init__(self, api_key: str, provider_fee: float = 1.06):
"""Initialize OpenRouter provider with API key.
@@ -12,13 +21,29 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
api_key: OpenRouter API key for authentication
provider_fee: Provider fee multiplier (default 1.06 for 6% fee)
"""
self.upstream_name = "openrouter"
super().__init__(
base_url="https://openrouter.ai/api/v1",
api_key=api_key,
provider_fee=provider_fee,
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "OpenRouterUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "OpenRouter",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
async def fetch_models(self) -> list[Model]:
"""Fetch all OpenRouter models."""
models_data = await async_fetch_openrouter_models()
+28 -4
View File
@@ -1,21 +1,45 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class PerplexityUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for OpenAI API."""
"""Upstream provider specifically configured for Perplexity API."""
upstream_name = "perplexity"
base_url = "https://api.perplexity.ai/" # without v1
provider_type = "perplexity"
default_base_url = "https://api.perplexity.ai/"
platform_url = "https://www.perplexity.ai/account/api/keys"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url,
base_url=self.default_base_url,
api_key=api_key,
provider_fee=provider_fee,
)
@classmethod
def from_db_row(
cls, provider_row: "UpstreamProviderRow"
) -> "PerplexityUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "Perplexity",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
"""Strip 'perplexity/' prefix for Perplexity API compatibility."""
return model_id.removeprefix("perplexity/")
+28 -6
View File
@@ -1,24 +1,46 @@
from typing import TYPE_CHECKING
from ..payment.models import Model, async_fetch_openrouter_models
from .base import BaseUpstreamProvider
if TYPE_CHECKING:
from ..core.db import UpstreamProviderRow
class XAIUpstreamProvider(BaseUpstreamProvider):
"""Upstream provider specifically configured for XAI API."""
upstream_name = "xai"
base_url = "https://api.x.ai/v1"
platform_url = "https://accounts.x.ai/sign-up"
provider_type = "x-ai"
default_base_url = "https://api.x.ai/v1"
platform_url = "https://console.x.ai/"
def __init__(self, api_key: str, provider_fee: float = 1.01):
super().__init__(
base_url=self.base_url, api_key=api_key, provider_fee=provider_fee
base_url=self.default_base_url, api_key=api_key, provider_fee=provider_fee
)
@classmethod
def from_db_row(cls, provider_row: "UpstreamProviderRow") -> "XAIUpstreamProvider":
return cls(
api_key=provider_row.api_key,
provider_fee=provider_row.provider_fee,
)
@classmethod
def get_provider_metadata(cls) -> dict[str, object]:
return {
"id": cls.provider_type,
"name": "xAI",
"default_base_url": cls.default_base_url,
"fixed_base_url": True,
"platform_url": cls.platform_url,
}
def transform_model_name(self, model_id: str) -> str:
"""Strip 'xai/' prefix for XAI API compatibility."""
return model_id.removeprefix("xai/")
return model_id.removeprefix("x-ai/")
async def fetch_models(self) -> list[Model]:
"""Fetch XAI models from OpenRouter API filtered by xai source."""
models_data = await async_fetch_openrouter_models(source_filter="xai")
models_data = await async_fetch_openrouter_models(source_filter="x-ai")
return [Model(**model) for model in models_data] # type: ignore
+1 -1
View File
@@ -512,7 +512,7 @@ async def integration_app(
from routstr.core.settings import settings as _settings
# Passthrough discounted max cost to avoid dependence on MODELS in tests
def _passthrough_discount(
async def _passthrough_discount(
max_cost_for_model: int,
body: dict,
model_obj: Any = None,
+1 -1
View File
@@ -49,7 +49,7 @@ def create_test_model(
def create_test_provider(name: str, base_url: str = "http://test.com") -> Mock:
"""Helper to create a test provider mock."""
provider = Mock()
provider.upstream_name = name
provider.provider_type = name
provider.base_url = base_url
return provider
+189
View File
@@ -0,0 +1,189 @@
import base64
from io import BytesIO
import pytest
from PIL import Image
from routstr.payment.helpers import (
_calculate_image_tokens,
_get_image_dimensions,
estimate_image_tokens_in_messages,
)
def create_test_image(width: int, height: int) -> bytes:
"""Create a test image with specified dimensions."""
img = Image.new("RGB", (width, height), color="red")
buffer = BytesIO()
img.save(buffer, format="JPEG")
return buffer.getvalue()
def test_calculate_image_tokens_low_detail() -> None:
"""Test that low detail images always return 85 tokens."""
assert _calculate_image_tokens(100, 100, "low") == 85
assert _calculate_image_tokens(1000, 1000, "low") == 85
assert _calculate_image_tokens(2048, 2048, "low") == 85
def test_calculate_image_tokens_high_detail_small() -> None:
"""Test token calculation for small images."""
tokens = _calculate_image_tokens(512, 512, "high")
assert tokens == 85 + 170
def test_calculate_image_tokens_high_detail_large() -> None:
"""Test token calculation for large images that need tiling."""
tokens = _calculate_image_tokens(768, 768, "high")
assert tokens > 85
def test_calculate_image_tokens_auto() -> None:
"""Test that auto detail behaves like high detail."""
width, height = 512, 512
auto_tokens = _calculate_image_tokens(width, height, "auto")
high_tokens = _calculate_image_tokens(width, height, "high")
assert auto_tokens == high_tokens
def test_get_image_dimensions() -> None:
"""Test extracting dimensions from image bytes."""
image_bytes = create_test_image(800, 600)
width, height = _get_image_dimensions(image_bytes)
assert width == 800
assert height == 600
def test_get_image_dimensions_invalid() -> None:
"""Test that invalid image data returns default dimensions."""
invalid_bytes = b"not an image"
width, height = _get_image_dimensions(invalid_bytes)
assert width == 512
assert height == 512
@pytest.mark.asyncio
async def test_estimate_image_tokens_base64() -> None:
"""Test estimating tokens for base64 encoded images."""
image_bytes = create_test_image(512, 512)
base64_image = base64.b64encode(image_bytes).decode("utf-8")
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
},
],
}
]
tokens = await estimate_image_tokens_in_messages(messages)
assert tokens > 0
@pytest.mark.asyncio
async def test_estimate_image_tokens_multiple_images() -> None:
"""Test estimating tokens for multiple images."""
image_bytes = create_test_image(512, 512)
base64_image = base64.b64encode(image_bytes).decode("utf-8")
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "Compare these images"},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
},
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{base64_image}"},
},
],
}
]
tokens = await estimate_image_tokens_in_messages(messages)
assert tokens > 0
@pytest.mark.asyncio
async def test_estimate_image_tokens_with_detail() -> None:
"""Test that detail parameter affects token calculation."""
image_bytes = create_test_image(512, 512)
base64_image = base64.b64encode(image_bytes).decode("utf-8")
messages_low = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}",
"detail": "low",
},
},
],
}
]
messages_high = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}",
"detail": "high",
},
},
],
}
]
tokens_low = await estimate_image_tokens_in_messages(messages_low)
tokens_high = await estimate_image_tokens_in_messages(messages_high)
assert tokens_low == 85
assert tokens_high > tokens_low
@pytest.mark.asyncio
async def test_estimate_image_tokens_no_images() -> None:
"""Test that messages without images return 0 tokens."""
messages = [
{"role": "user", "content": "Hello, how are you?"},
{"role": "assistant", "content": "I'm doing well, thank you!"},
]
tokens = await estimate_image_tokens_in_messages(messages)
assert tokens == 0
@pytest.mark.asyncio
async def test_estimate_image_tokens_input_image_type() -> None:
"""Test that input_image type is also supported."""
image_bytes = create_test_image(512, 512)
base64_image = base64.b64encode(image_bytes).decode("utf-8")
messages = [
{
"role": "user",
"content": [
{
"type": "input_image",
"image_url": f"data:image/jpeg;base64,{base64_image}",
},
],
}
]
tokens = await estimate_image_tokens_in_messages(messages)
assert tokens > 0
+49 -6
View File
@@ -10,6 +10,12 @@ import { TemporaryBalances } from '@/components/temporary-balances';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { DisplayUnit } from '@/lib/types/units';
import { fetchBtcUsdPrice, btcToSatsRate } from '@/lib/exchange-rate';
import { AdminService } from '@/lib/api/services/admin';
import { InitialSetupOnboarding } from '@/components/initial-setup-onboarding';
type AdminSettingsResponse = Record<string, unknown> & {
admin_password?: string | null;
};
export default function Page() {
const [displayUnit, setDisplayUnit] = useState<DisplayUnit>('sat');
@@ -21,6 +27,38 @@ export default function Page() {
staleTime: 60_000,
});
const {
data: settingsData,
isLoading: settingsLoading,
isError: settingsError,
} = useQuery<AdminSettingsResponse>({
queryKey: ['admin-settings'],
queryFn: async () => (await AdminService.getSettings()) as AdminSettingsResponse,
staleTime: 60_000,
});
const {
data: upstreamProviders,
isLoading: providersLoading,
isError: providersError,
} = useQuery({
queryKey: ['upstream-providers'],
queryFn: AdminService.getUpstreamProviders,
staleTime: 60_000,
});
const adminPasswordValue =
typeof settingsData?.admin_password === 'string' ? settingsData.admin_password : '';
const hasAdminPassword = adminPasswordValue.trim().length > 0;
const hasUpstreamProviders = (upstreamProviders?.length ?? 0) > 0;
const shouldShowOnboarding =
!settingsLoading &&
!providersLoading &&
!settingsError &&
!providersError &&
!hasAdminPassword &&
!hasUpstreamProviders;
const usdPerSat = btcUsdPrice ? btcToSatsRate(btcUsdPrice) : null;
useEffect(() => {
@@ -37,12 +75,8 @@ export default function Page() {
<div className='container max-w-6xl px-4 py-8 md:px-6 lg:px-8'>
<div className='mb-8 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div>
<h1 className='text-3xl font-bold tracking-tight'>
Admin Dashboard
</h1>
<p className='text-muted-foreground mt-2'>
Monitor and manage wallet balances
</p>
<h1 className='text-3xl font-bold tracking-tight'>Admin Dashboard</h1>
<p className='text-muted-foreground mt-2'>Monitor and manage wallet balances</p>
</div>
<div className='flex items-center'>
<ToggleGroup
@@ -65,6 +99,15 @@ export default function Page() {
</div>
</div>
{shouldShowOnboarding && (
<div className='mb-8'>
<InitialSetupOnboarding
hasAdminPassword={hasAdminPassword}
hasUpstreamProviders={hasUpstreamProviders}
/>
</div>
)}
<div className='grid gap-6'>
<div className='col-span-full'>
<DetailedWalletBalance
+33 -4
View File
@@ -194,6 +194,11 @@ export default function ProvidersPage() {
return providerType?.fixed_base_url || false;
};
const getPlatformUrl = (type: string) => {
const providerType = providerTypes.find((pt) => pt.id === type);
return providerType?.platform_url || null;
};
const toggleProviderExpansion = (providerId: number) => {
const newExpanded = new Set(expandedProviders);
if (newExpanded.has(providerId)) {
@@ -285,7 +290,19 @@ export default function ProvidersPage() {
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='api_key'>API Key</Label>
<div className='flex items-center justify-between'>
<Label htmlFor='api_key'>API Key</Label>
{getPlatformUrl(formData.provider_type) && (
<a
href={getPlatformUrl(formData.provider_type)!}
target='_blank'
rel='noopener noreferrer'
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
>
Get Your API Key Here
</a>
)}
</div>
<Input
id='api_key'
type='password'
@@ -694,9 +711,21 @@ export default function ProvidersPage() {
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='edit_api_key'>
API Key (leave blank to keep current)
</Label>
<div className='flex items-center justify-between'>
<Label htmlFor='edit_api_key'>
API Key (leave blank to keep current)
</Label>
{getPlatformUrl(formData.provider_type) && (
<a
href={getPlatformUrl(formData.provider_type)!}
target='_blank'
rel='noopener noreferrer'
className='text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300'
>
Get Your API Key Here
</a>
)}
</div>
<Input
id='edit_api_key'
type='password'
+231
View File
@@ -0,0 +1,231 @@
'use client';
import Link from 'next/link';
import { type LucideIcon, CheckCircle2, Circle, Lock, PlugZap, ServerCog, Settings2, Wallet } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { cn } from '@/lib/utils';
interface InitialSetupOnboardingProps {
hasAdminPassword: boolean;
hasUpstreamProviders: boolean;
}
type CriticalStep = {
id: string;
title: string;
description: string;
helper: string;
href: string;
actionLabel: string;
completed: boolean;
icon: LucideIcon;
};
type FocusArea = {
id: string;
title: string;
description: string;
bullets: string[];
href: string;
actionLabel: string;
icon: LucideIcon;
};
export function InitialSetupOnboarding({
hasAdminPassword,
hasUpstreamProviders,
}: InitialSetupOnboardingProps) {
const criticalSteps: CriticalStep[] = [
{
id: 'admin-password',
title: 'Secure the dashboard',
description:
'Create a unique admin password to mint login tokens and protect the control plane.',
helper:
'Navigate to Settings → Change Admin Password. The UI will prompt for the current value if one already exists.',
href: '/settings',
actionLabel: hasAdminPassword ? 'Review password policy' : 'Set password',
completed: hasAdminPassword,
icon: Lock,
},
{
id: 'providers',
title: 'Connect an upstream provider',
description:
'Add at least one upstream LLM provider so Routstr can relay requests on behalf of your users.',
helper:
'Open the Providers workspace to choose OpenAI, Anthropic, Groq, or point to your own inference endpoint.',
href: '/providers',
actionLabel: hasUpstreamProviders ? 'Review providers' : 'Add provider',
completed: hasUpstreamProviders,
icon: PlugZap,
},
];
const focusAreas: FocusArea[] = [
{
id: 'node-profile',
title: 'Node identity & endpoints',
description:
'Give your node a friendly name and publish both clearnet and Tor URLs so clients know where to connect.',
bullets: [
'Fill in Name, Description, HTTP URL, and optional Onion URL under Basic Information.',
'These fields populate the metadata served to wallets and the public API.',
],
href: '/settings',
actionLabel: 'Edit profile',
icon: Settings2,
},
{
id: 'cashu',
title: 'Cashu mint configuration',
description:
'Your node escrows sats per request. Keep the wallet funded and list every mint you plan to settle with.',
bullets: [
'Add each mint URL and test a small deposit to verify proofs sync correctly.',
'Monitor the Detailed Wallet widget to ensure owner balance stays positive.',
],
href: '/settings',
actionLabel: 'Manage mints',
icon: Wallet,
},
{
id: 'models',
title: 'Model catalog & pricing',
description:
'Once a provider is connected, import models or define custom SKUs so downstream clients have predictable IDs.',
bullets: [
'Use the Providers page to sync remote catalogs or create per-model pricing in sats.',
'Assign provider fees and availability so routing honors your margins.',
],
href: '/providers',
actionLabel: 'Curate catalog',
icon: ServerCog,
},
];
const completedSteps = criticalSteps.filter((step) => step.completed).length;
const progress = (completedSteps / criticalSteps.length) * 100;
return (
<Card className='border-primary/40 bg-primary/5 shadow-sm dark:bg-primary/10'>
<CardHeader>
<Badge variant='warning' className='w-fit text-xs uppercase tracking-wide'>
Initial setup
</Badge>
<CardTitle className='text-xl'>Finish onboarding your Routstr node</CardTitle>
<CardDescription>
Lock down the admin dashboard, add an upstream provider, and review the remaining
configuration so routing works end-to-end.
</CardDescription>
</CardHeader>
<CardContent className='space-y-8'>
<div>
<div className='flex items-center justify-between text-sm text-muted-foreground'>
<span>
{completedSteps} of {criticalSteps.length} critical steps complete
</span>
<span className='font-semibold text-primary'>{Math.round(progress)}%</span>
</div>
<Progress value={progress} className='mt-2 h-2' />
</div>
<ol className='space-y-4'>
{criticalSteps.map((step, index) => {
const Icon = step.icon;
const StatusIcon = step.completed ? CheckCircle2 : Circle;
return (
<li
key={step.id}
className='rounded-xl border bg-background/80 p-5 shadow-sm transition hover:border-primary/50'
>
<div className='flex flex-col gap-4 md:flex-row md:items-center'>
<div className='flex flex-1 items-start gap-4'>
<Badge
variant='secondary'
className='mt-0.5 flex size-8 items-center justify-center rounded-full text-base font-semibold'
>
{index + 1}
</Badge>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<Icon className='size-5 text-primary' />
<p className='text-base font-semibold'>{step.title}</p>
</div>
<p className='text-sm text-muted-foreground'>{step.description}</p>
<p className='text-xs text-muted-foreground'>{step.helper}</p>
</div>
</div>
<div className='flex w-full flex-col gap-2 sm:w-auto'>
<div
className={cn(
'flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium',
step.completed
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10'
: 'bg-muted text-muted-foreground'
)}
>
<StatusIcon
className={cn(
'size-4',
step.completed ? 'text-emerald-600' : 'text-muted-foreground'
)}
/>
{step.completed ? 'Completed' : 'Action required'}
</div>
<Button asChild size='sm' variant={step.completed ? 'outline' : 'default'}>
<Link href={step.href}>{step.actionLabel}</Link>
</Button>
</div>
</div>
</li>
);
})}
</ol>
<div className='grid gap-4 md:grid-cols-3'>
{focusAreas.map((area) => {
const Icon = area.icon;
return (
<div key={area.id} className='rounded-xl border bg-card/70 p-4'>
<div className='flex items-center gap-2 text-sm font-semibold'>
<Icon className='size-4 text-primary' />
{area.title}
</div>
<p className='mt-2 text-sm text-muted-foreground'>{area.description}</p>
<ul className='mt-3 space-y-1 text-xs text-muted-foreground'>
{area.bullets.map((bullet) => (
<li key={bullet} className='flex gap-2'>
<span className='text-primary'></span>
<span>{bullet}</span>
</li>
))}
</ul>
<Button asChild size='sm' variant='secondary' className='mt-4 w-full'>
<Link href={area.href}>{area.actionLabel}</Link>
</Button>
</div>
);
})}
</div>
<div className='rounded-xl border bg-muted/40 p-4 text-sm leading-relaxed text-muted-foreground'>
<p className='font-semibold text-foreground'>How the flow works</p>
<p className='mt-2'>
Routstr reserves the maximum cost for each request up front, relays it to the selected
upstream provider, and then reconciles the actual token usage when the response returns.
Keeping an admin password, provider credentials, and Cashu balances in sync ensures every
client request can be authorized and settled automatically.
</p>
<p className='mt-2'>
As soon as you set the password and register a provider this onboarding guide disappears
automatically.
</p>
</div>
</CardContent>
</Card>
);
}
+1
View File
@@ -6,6 +6,7 @@ export const ProviderTypeSchema = z.object({
name: z.string(),
default_base_url: z.string(),
fixed_base_url: z.boolean(),
platform_url: z.string().nullable(),
});
export const UpstreamProviderSchema = z.object({
Generated
+98 -1
View File
@@ -808,6 +808,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684 },
{ url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647 },
{ url = "https://files.pythonhosted.org/packages/3f/cc/b07000438a29ac5cfb2194bfc128151d52f333cee74dd7dfe3fb733fc16c/greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa", size = 1142073 },
{ url = "https://files.pythonhosted.org/packages/67/24/28a5b2fa42d12b3d7e5614145f0bd89714c34c08be6aabe39c14dd52db34/greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c", size = 1548385 },
{ url = "https://files.pythonhosted.org/packages/6a/05/03f2f0bdd0b0ff9a4f7b99333d57b53a7709c27723ec8123056b084e69cd/greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5", size = 1613329 },
{ url = "https://files.pythonhosted.org/packages/d8/0f/30aef242fcab550b0b3520b8e3561156857c94288f0332a79928c31a52cf/greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9", size = 299100 },
{ url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079 },
{ url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997 },
@@ -817,6 +819,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586 },
{ url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281 },
{ url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142 },
{ url = "https://files.pythonhosted.org/packages/27/45/80935968b53cfd3f33cf99ea5f08227f2646e044568c9b1555b58ffd61c2/greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0", size = 1564846 },
{ url = "https://files.pythonhosted.org/packages/69/02/b7c30e5e04752cb4db6202a3858b149c0710e5453b71a3b2aec5d78a1aab/greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d", size = 1633814 },
{ url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899 },
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814 },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073 },
@@ -826,6 +830,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497 },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662 },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210 },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759 },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288 },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685 },
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586 },
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346 },
@@ -833,6 +839,8 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659 },
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355 },
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512 },
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508 },
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760 },
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425 },
]
@@ -1414,6 +1422,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 },
]
[[package]]
name = "pillow"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798 },
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589 },
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472 },
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887 },
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964 },
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756 },
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075 },
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955 },
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440 },
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256 },
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025 },
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377 },
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343 },
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981 },
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399 },
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740 },
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201 },
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334 },
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162 },
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769 },
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107 },
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012 },
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493 },
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461 },
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912 },
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132 },
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099 },
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808 },
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804 },
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553 },
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729 },
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789 },
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917 },
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391 },
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477 },
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918 },
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406 },
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218 },
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564 },
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260 },
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248 },
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043 },
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915 },
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998 },
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201 },
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165 },
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834 },
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531 },
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554 },
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812 },
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689 },
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186 },
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308 },
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222 },
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657 },
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482 },
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416 },
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584 },
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621 },
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916 },
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836 },
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092 },
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158 },
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882 },
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001 },
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146 },
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344 },
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864 },
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911 },
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045 },
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282 },
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630 },
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068 },
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994 },
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639 },
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839 },
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505 },
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654 },
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -1783,7 +1878,7 @@ wheels = [
[[package]]
name = "routstr"
version = "0.2.0"
version = "0.2.0c0"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1795,6 +1890,7 @@ dependencies = [
{ name = "marshmallow" },
{ name = "mdurl" },
{ name = "nostr" },
{ name = "pillow" },
{ name = "python-json-logger" },
{ name = "secp256k1" },
{ name = "sqlmodel" },
@@ -1827,6 +1923,7 @@ requires-dist = [
{ name = "marshmallow", specifier = ">=3.13,<4.0" },
{ name = "mdurl", specifier = "==0.1.2" },
{ name = "nostr", specifier = ">=0.0.2" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "python-json-logger", specifier = ">=2.0.0" },
{ name = "secp256k1", git = "https://github.com/saschanaz/secp256k1-py?branch=upgrade060" },
{ name = "sqlmodel", specifier = ">=0.0.24" },
Vendored Submodule
+1
Submodule vendor/cdk added at 52d796e9fe
Vendored Submodule
+1
Submodule vendor/cdk-python added at 915c6966b0