Merge pull request #14 from Routstr/sixty-nuts-migration

Sixty nuts migration
This commit is contained in:
shroominic
2025-06-05 15:08:29 +02:00
committed by GitHub
17 changed files with 1067 additions and 1713 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Run Tests
on:
push:
branches: ["*"] # Run on all branches
pull_request:
branches: ["*"] # Run on PRs to all branches
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v3
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- name: Install dependencies
run: |
uv sync --dev
- name: Run linting with ruff
run: |
uv run ruff check .
- name: Run type checking with mypy
run: |
uv run mypy .
- name: Run tests with pytest
run: |
uv run pytest --verbose --tb=short
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.python-version }}
path: |
pytest.xml
.coverage
retention-days: 30
+1 -3
View File
@@ -4,7 +4,6 @@ FROM ghcr.io/astral-sh/uv:python3.11-alpine
RUN apk add --no-cache \
pkgconf \
build-base \
libsecp256k1-dev \
automake \
autoconf \
libtool \
@@ -14,8 +13,7 @@ RUN apk add git
COPY uv.lock pyproject.toml ./
RUN uv add git+https://github.com/saschanaz/secp256k1-py.git#branch=upgrade060
# RUN uv sync
RUN uv sync
WORKDIR /app
+309 -295
View File
@@ -1,103 +1,5 @@
{
"models": [
{
"id": "google/gemma-2b-it",
"hugging_face_id": "google/gemma-2b-it",
"name": "Google: Gemma 2 2B",
"created": 1748460815,
"description": "Gemma 2 2B by Google is an open model built from the same research and technology used to create the [Gemini models](/models?q=gemini).\n\nGemma models are well-suited for a variety of text generation tasks, including question answering, summarization, and reasoning.\n\nSee the [launch announcement](https://blog.google/technology/developers/google-gemma-2/) for more details. Usage of Gemma is subject to Google's [Gemma Terms of Use](https://ai.google.dev/gemma/terms).",
"context_length": 8192,
"architecture": {
"modality": "text->text",
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"tokenizer": "Gemini",
"instruct_type": "gemma"
},
"pricing": {
"prompt": "0.0000001",
"completion": "0.0000001",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 8192,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"top_k",
"repetition_penalty",
"logit_bias",
"min_p",
"response_format"
]
},
{
"id": "deepseek/deepseek-r1-0528",
"hugging_face_id": "deepseek-ai/DeepSeek-R1-0528",
"name": "DeepSeek: R1 0528",
"created": 1748455170,
"description": "May 28th update to the [original DeepSeek R1](/deepseek/deepseek-r1) Performance on par with [OpenAI o1](/openai/o1), but open-sourced and with fully open reasoning tokens. It's 671B parameters in size, with 37B active in an inference pass.\n\nFully open-source model.",
"context_length": 163840,
"architecture": {
"modality": "text->text",
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"tokenizer": "DeepSeek",
"instruct_type": "deepseek-r1"
},
"pricing": {
"prompt": "0.0000005",
"completion": "0.00000218",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 163840,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"top_p",
"reasoning",
"include_reasoning",
"presence_penalty",
"frequency_penalty",
"repetition_penalty",
"top_k",
"stop",
"seed",
"min_p",
"logit_bias",
"top_logprobs",
"logprobs",
"response_format",
"structured_outputs"
]
},
{
"id": "sarvamai/sarvam-m",
"hugging_face_id": "sarvamai/sarvam-m",
@@ -263,7 +165,7 @@
"top_provider": {
"context_length": 200000,
"max_completion_tokens": 64000,
"is_moderated": false
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
@@ -1167,7 +1069,7 @@
},
"top_provider": {
"context_length": 128000,
"max_completion_tokens": 20000,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
@@ -1273,20 +1175,20 @@
},
"per_request_limits": null,
"supported_parameters": [
"tools",
"tool_choice",
"max_tokens",
"temperature",
"top_p",
"reasoning",
"include_reasoning",
"seed",
"tools",
"tool_choice",
"stop",
"frequency_penalty",
"presence_penalty",
"repetition_penalty",
"response_format",
"top_k",
"seed",
"min_p",
"structured_outputs",
"logprobs",
@@ -1332,20 +1234,20 @@
"top_p",
"reasoning",
"include_reasoning",
"stop",
"frequency_penalty",
"presence_penalty",
"top_k",
"frequency_penalty",
"repetition_penalty",
"logit_bias",
"min_p",
"response_format",
"seed",
"top_k",
"tools",
"tool_choice",
"stop",
"response_format",
"structured_outputs",
"logit_bias",
"logprobs",
"top_logprobs"
"top_logprobs",
"seed",
"min_p"
]
},
{
@@ -1376,7 +1278,7 @@
},
"top_provider": {
"context_length": 32000,
"max_completion_tokens": 32000,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
@@ -1424,7 +1326,7 @@
},
"top_provider": {
"context_length": 32000,
"max_completion_tokens": 32000,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
@@ -1472,7 +1374,7 @@
},
"top_provider": {
"context_length": 32000,
"max_completion_tokens": 32000,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
@@ -1666,18 +1568,10 @@
"supported_parameters": [
"tools",
"tool_choice",
"max_tokens",
"reasoning",
"include_reasoning",
"structured_outputs",
"response_format",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"logit_bias",
"logprobs",
"top_logprobs"
"max_tokens",
"response_format",
"structured_outputs"
]
},
{
@@ -1723,6 +1617,52 @@
"structured_outputs"
]
},
{
"id": "qwen/qwen2.5-coder-7b-instruct",
"hugging_face_id": "Qwen/Qwen2.5-Coder-7B-Instruct",
"name": "Qwen: Qwen2.5 Coder 7B Instruct",
"created": 1744734887,
"description": "Qwen2.5-Coder-7B-Instruct is a 7B parameter instruction-tuned language model optimized for code-related tasks such as code generation, reasoning, and bug fixing. Based on the Qwen2.5 architecture, it incorporates enhancements like RoPE, SwiGLU, RMSNorm, and GQA attention with support for up to 128K tokens using YaRN-based extrapolation. It is trained on a large corpus of source code, synthetic data, and text-code grounding, providing robust performance across programming languages and agentic coding workflows.\n\nThis model is part of the Qwen2.5-Coder family and offers strong compatibility with tools like vLLM for efficient deployment. Released under the Apache 2.0 license.",
"context_length": 32768,
"architecture": {
"modality": "text->text",
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"tokenizer": "Qwen",
"instruct_type": null
},
"pricing": {
"prompt": "0.00000001",
"completion": "0.00000003",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 32768,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"logit_bias",
"logprobs",
"top_logprobs"
]
},
{
"id": "openai/gpt-4.1",
"hugging_face_id": "",
@@ -2111,54 +2051,6 @@
"top_logprobs"
]
},
{
"id": "nvidia/llama-3.1-nemotron-ultra-253b-v1",
"hugging_face_id": "nvidia/Llama-3_1-Nemotron-Ultra-253B-v1",
"name": "NVIDIA: Llama 3.1 Nemotron Ultra 253B v1",
"created": 1744115059,
"description": "Llama-3.1-Nemotron-Ultra-253B-v1 is a large language model (LLM) optimized for advanced reasoning, human-interactive chat, retrieval-augmented generation (RAG), and tool-calling tasks. Derived from Meta\u2019s Llama-3.1-405B-Instruct, it has been significantly customized using Neural Architecture Search (NAS), resulting in enhanced efficiency, reduced memory usage, and improved inference latency. The model supports a context length of up to 128K tokens and can operate efficiently on an 8x NVIDIA H100 node.\n\nNote: you must include `detailed thinking on` in the system prompt to enable reasoning. Please see [Usage Recommendations](https://huggingface.co/nvidia/Llama-3_1-Nemotron-Ultra-253B-v1#quick-start-and-usage-recommendations) for more.",
"context_length": 131072,
"architecture": {
"modality": "text->text",
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"tokenizer": "Llama3",
"instruct_type": null
},
"pricing": {
"prompt": "0.0000006",
"completion": "0.0000018",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 131072,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"top_p",
"reasoning",
"include_reasoning",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"logit_bias",
"logprobs",
"top_logprobs"
]
},
{
"id": "meta-llama/llama-4-maverick",
"hugging_face_id": "meta-llama/Llama-4-Maverick-17B-128E-Instruct",
@@ -2470,8 +2362,8 @@
"instruct_type": null
},
"pricing": {
"prompt": "0.0000009",
"completion": "0.0000009",
"prompt": "0.0000008",
"completion": "0.0000008",
"request": "0",
"image": "0",
"web_search": "0",
@@ -2479,7 +2371,7 @@
},
"top_provider": {
"context_length": 128000,
"max_completion_tokens": null,
"max_completion_tokens": 128000,
"is_moderated": false
},
"per_request_limits": null,
@@ -3312,6 +3204,8 @@
"logprobs",
"top_logprobs",
"seed",
"tools",
"tool_choice",
"structured_outputs"
]
},
@@ -3450,15 +3344,62 @@
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"stop",
"reasoning",
"include_reasoning",
"tools",
"tool_choice",
"max_tokens",
"temperature",
"top_p",
"top_k"
"reasoning",
"include_reasoning",
"top_k",
"stop"
]
},
{
"id": "anthropic/claude-3.7-sonnet:thinking",
"hugging_face_id": "",
"name": "Anthropic: Claude 3.7 Sonnet (thinking)",
"created": 1740422110,
"description": "Claude 3.7 Sonnet is an advanced large language model with improved reasoning, coding, and problem-solving capabilities. It introduces a hybrid reasoning approach, allowing users to choose between rapid responses and extended, step-by-step processing for complex tasks. The model demonstrates notable improvements in coding, particularly in front-end development and full-stack updates, and excels in agentic workflows, where it can autonomously navigate multi-step processes. \n\nClaude 3.7 Sonnet maintains performance parity with its predecessor in standard mode while offering an extended reasoning mode for enhanced accuracy in math, coding, and instruction-following tasks.\n\nRead more at the [blog post here](https://www.anthropic.com/news/claude-3-7-sonnet)",
"context_length": 200000,
"architecture": {
"modality": "text+image->text",
"input_modalities": [
"text",
"image"
],
"output_modalities": [
"text"
],
"tokenizer": "Claude",
"instruct_type": null
},
"pricing": {
"prompt": "0.000003",
"completion": "0.000015",
"request": "0",
"image": "0.0048",
"web_search": "0",
"internal_reasoning": "0",
"input_cache_read": "0.0000003",
"input_cache_write": "0.00000375"
},
"top_provider": {
"context_length": 200000,
"max_completion_tokens": 64000,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"tools",
"tool_choice",
"max_tokens",
"temperature",
"top_p",
"reasoning",
"include_reasoning",
"top_k",
"stop"
]
},
{
@@ -3506,51 +3447,6 @@
"tool_choice"
]
},
{
"id": "anthropic/claude-3.7-sonnet:thinking",
"hugging_face_id": "",
"name": "Anthropic: Claude 3.7 Sonnet (thinking)",
"created": 1740422110,
"description": "Claude 3.7 Sonnet is an advanced large language model with improved reasoning, coding, and problem-solving capabilities. It introduces a hybrid reasoning approach, allowing users to choose between rapid responses and extended, step-by-step processing for complex tasks. The model demonstrates notable improvements in coding, particularly in front-end development and full-stack updates, and excels in agentic workflows, where it can autonomously navigate multi-step processes. \n\nClaude 3.7 Sonnet maintains performance parity with its predecessor in standard mode while offering an extended reasoning mode for enhanced accuracy in math, coding, and instruction-following tasks.\n\nRead more at the [blog post here](https://www.anthropic.com/news/claude-3-7-sonnet)",
"context_length": 200000,
"architecture": {
"modality": "text+image->text",
"input_modalities": [
"text",
"image"
],
"output_modalities": [
"text"
],
"tokenizer": "Claude",
"instruct_type": null
},
"pricing": {
"prompt": "0.000003",
"completion": "0.000015",
"request": "0",
"image": "0.0048",
"web_search": "0",
"internal_reasoning": "0",
"input_cache_read": "0.0000003",
"input_cache_write": "0.00000375"
},
"top_provider": {
"context_length": 200000,
"max_completion_tokens": 128000,
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"stop",
"reasoning",
"include_reasoning",
"tools",
"tool_choice"
]
},
{
"id": "perplexity/r1-1776",
"hugging_face_id": "perplexity-ai/r1-1776",
@@ -4123,12 +4019,12 @@
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"min_p",
"repetition_penalty",
"logit_bias",
"min_p",
"response_format",
"seed",
"logprobs",
"top_logprobs"
]
@@ -4403,12 +4299,12 @@
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"min_p",
"repetition_penalty",
"logit_bias",
"response_format"
"response_format",
"top_k",
"seed",
"min_p",
"logit_bias"
]
},
{
@@ -4439,7 +4335,7 @@
},
"top_provider": {
"context_length": 64000,
"max_completion_tokens": 32000,
"max_completion_tokens": 64000,
"is_moderated": false
},
"per_request_limits": null,
@@ -4452,12 +4348,12 @@
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"min_p",
"repetition_penalty",
"logit_bias",
"response_format"
"min_p",
"response_format",
"seed"
]
},
{
@@ -4714,7 +4610,7 @@
"instruct_type": "deepseek-r1"
},
"pricing": {
"prompt": "0.00000045",
"prompt": "0.0000005",
"completion": "0.00000218",
"request": "0",
"image": "0",
@@ -4738,13 +4634,13 @@
"presence_penalty",
"seed",
"top_k",
"min_p",
"logit_bias",
"logprobs",
"top_logprobs",
"repetition_penalty",
"response_format",
"structured_outputs",
"logprobs",
"repetition_penalty",
"min_p",
"tools",
"tool_choice"
]
@@ -5250,13 +5146,13 @@
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"min_p",
"repetition_penalty",
"logit_bias",
"logprobs",
"top_logprobs",
"response_format",
"top_k",
"min_p",
"repetition_penalty",
"structured_outputs"
]
},
@@ -5404,8 +5300,8 @@
"instruct_type": "deepseek-r1"
},
"pricing": {
"prompt": "0.0000002",
"completion": "0.0000002",
"prompt": "0.00000009",
"completion": "0.00000027",
"request": "0",
"image": "0",
"web_search": "0",
@@ -6914,7 +6810,8 @@
"seed",
"min_p",
"logit_bias",
"top_logprobs"
"top_logprobs",
"logprobs"
]
},
{
@@ -7002,14 +6899,14 @@
"max_tokens",
"temperature",
"top_p",
"top_k",
"seed",
"repetition_penalty",
"stop",
"frequency_penalty",
"presence_penalty",
"stop",
"logit_bias",
"seed",
"top_k",
"min_p",
"repetition_penalty",
"logit_bias",
"response_format",
"top_logprobs",
"tools",
@@ -7515,7 +7412,7 @@
"name": "Microsoft: Phi-3.5 Mini 128K Instruct",
"created": 1724198400,
"description": "Phi-3.5 models are lightweight, state-of-the-art open models. These models were trained with Phi-3 datasets that include both synthetic data and the filtered, publicly available websites data, with a focus on high quality and reasoning-dense properties. Phi-3.5 Mini uses 3.8B parameters, and is a dense decoder-only transformer model using the same tokenizer as [Phi-3 Mini](/models/microsoft/phi-3-mini-128k-instruct).\n\nThe models underwent a rigorous enhancement process, incorporating both supervised fine-tuning, proximal policy optimization, and direct preference optimization to ensure precise instruction adherence and robust safety measures. When assessed against benchmarks that test common sense, language understanding, math, code, long context and logical reasoning, Phi-3.5 models showcased robust and state-of-the-art performance among models with less than 13 billion parameters.",
"context_length": 128000,
"context_length": 131072,
"architecture": {
"modality": "text->text",
"input_modalities": [
@@ -7528,15 +7425,15 @@
"instruct_type": "phi3"
},
"pricing": {
"prompt": "0.0000001",
"completion": "0.0000001",
"prompt": "0.00000003",
"completion": "0.00000009",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 128000,
"context_length": 131072,
"max_completion_tokens": null,
"is_moderated": false
},
@@ -7546,7 +7443,15 @@
"tool_choice",
"max_tokens",
"temperature",
"top_p"
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"logit_bias",
"logprobs",
"top_logprobs"
]
},
{
@@ -7815,12 +7720,13 @@
"request": "0",
"image": "0.003613",
"web_search": "0",
"internal_reasoning": "0"
"internal_reasoning": "0",
"input_cache_read": "0.00000125"
},
"top_provider": {
"context_length": 128000,
"max_completion_tokens": 16384,
"is_moderated": false
"is_moderated": true
},
"per_request_limits": null,
"supported_parameters": [
@@ -8245,7 +8151,7 @@
},
"top_provider": {
"context_length": 131072,
"max_completion_tokens": 16384,
"max_completion_tokens": 131072,
"is_moderated": false
},
"per_request_limits": null,
@@ -8256,17 +8162,17 @@
"stop",
"frequency_penalty",
"presence_penalty",
"repetition_penalty",
"response_format",
"top_k",
"seed",
"min_p",
"top_k",
"logit_bias",
"logprobs",
"top_logprobs",
"tools",
"tool_choice",
"structured_outputs"
"response_format",
"structured_outputs",
"repetition_penalty",
"min_p"
]
},
{
@@ -8394,8 +8300,8 @@
"instruct_type": "gemma"
},
"pricing": {
"prompt": "0.0000008",
"completion": "0.0000008",
"prompt": "0.0000001",
"completion": "0.0000003",
"request": "0",
"image": "0",
"web_search": "0",
@@ -8403,7 +8309,7 @@
},
"top_provider": {
"context_length": 8192,
"max_completion_tokens": 2048,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
@@ -8418,7 +8324,10 @@
"repetition_penalty",
"logit_bias",
"min_p",
"response_format"
"response_format",
"seed",
"logprobs",
"top_logprobs"
]
},
{
@@ -8485,8 +8394,8 @@
"instruct_type": "gemma"
},
"pricing": {
"prompt": "0.0000002",
"completion": "0.0000002",
"prompt": "0.00000002",
"completion": "0.00000006",
"request": "0",
"image": "0",
"web_search": "0",
@@ -8494,7 +8403,7 @@
},
"top_provider": {
"context_length": 8192,
"max_completion_tokens": 8192,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
@@ -8505,11 +8414,14 @@
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"min_p",
"repetition_penalty",
"logit_bias",
"response_format",
"top_logprobs",
"logprobs",
"logit_bias",
"seed"
"logprobs"
]
},
{
@@ -8985,7 +8897,7 @@
"name": "Microsoft: Phi-3 Medium 128K Instruct",
"created": 1716508800,
"description": "Phi-3 128K Medium is a powerful 14-billion parameter model designed for advanced language understanding, reasoning, and instruction following. Optimized through supervised fine-tuning and preference adjustments, it excels in tasks involving common sense, mathematics, logical reasoning, and code processing.\n\nAt time of release, Phi-3 Medium demonstrated state-of-the-art performance among lightweight models. In the MMLU-Pro eval, the model even comes close to a Llama3 70B level of performance.\n\nFor 4k context length, try [Phi-3 Medium 4K](/models/microsoft/phi-3-medium-4k-instruct).",
"context_length": 128000,
"context_length": 131072,
"architecture": {
"modality": "text->text",
"input_modalities": [
@@ -8998,15 +8910,15 @@
"instruct_type": "phi3"
},
"pricing": {
"prompt": "0.000001",
"completion": "0.000001",
"prompt": "0.0000001",
"completion": "0.0000003",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 128000,
"context_length": 131072,
"max_completion_tokens": null,
"is_moderated": false
},
@@ -9016,7 +8928,15 @@
"tool_choice",
"max_tokens",
"temperature",
"top_p"
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"logit_bias",
"logprobs",
"top_logprobs"
]
},
{
@@ -9064,6 +8984,52 @@
"seed"
]
},
{
"id": "deepseek/deepseek-coder",
"hugging_face_id": "deepseek-ai/DeepSeek-Coder-V2-Instruct",
"name": "DeepSeek-Coder-V2",
"created": 1715644800,
"description": "DeepSeek-Coder-V2, an open-source Mixture-of-Experts (MoE) code language model. It is further pre-trained from an intermediate checkpoint of DeepSeek-V2 with additional 6 trillion tokens.\n\nThe original V1 model was trained from scratch on 2T tokens, with a composition of 87% code and 13% natural language in both English and Chinese. It was pre-trained on project-level code corpus by employing a extra fill-in-the-blank task.",
"context_length": 128000,
"architecture": {
"modality": "text->text",
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"tokenizer": "Other",
"instruct_type": null
},
"pricing": {
"prompt": "0.00000004",
"completion": "0.00000012",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 128000,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"logit_bias",
"logprobs",
"top_logprobs"
]
},
{
"id": "google/gemini-flash-1.5",
"hugging_face_id": null,
@@ -9316,6 +9282,52 @@
"structured_outputs"
]
},
{
"id": "allenai/olmo-7b-instruct",
"hugging_face_id": "allenai/OLMo-7B-Instruct",
"name": "OLMo 7B Instruct",
"created": 1715299200,
"description": "OLMo 7B Instruct by the Allen Institute for AI is a model finetuned for question answering. It demonstrates **notable performance** across multiple benchmarks including TruthfulQA and ToxiGen.\n\n**Open Source**: The model, its code, checkpoints, logs are released under the [Apache 2.0 license](https://choosealicense.com/licenses/apache-2.0).\n\n- [Core repo (training, inference, fine-tuning etc.)](https://github.com/allenai/OLMo)\n- [Evaluation code](https://github.com/allenai/OLMo-Eval)\n- [Further fine-tuning code](https://github.com/allenai/open-instruct)\n- [Paper](https://arxiv.org/abs/2402.00838)\n- [Technical blog post](https://blog.allenai.org/olmo-open-language-model-87ccfc95f580)\n- [W&B Logs](https://wandb.ai/ai2-llm/OLMo-7B/reports/OLMo-7B--Vmlldzo2NzQyMzk5)",
"context_length": 2048,
"architecture": {
"modality": "text->text",
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"tokenizer": "Other",
"instruct_type": "zephyr"
},
"pricing": {
"prompt": "0.00000008",
"completion": "0.00000024",
"request": "0",
"image": "0",
"web_search": "0",
"internal_reasoning": "0"
},
"top_provider": {
"context_length": 2048,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
"supported_parameters": [
"max_tokens",
"temperature",
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"logit_bias",
"logprobs",
"top_logprobs"
]
},
{
"id": "neversleep/llama-3-lumimaid-8b",
"hugging_face_id": "NeverSleep/Llama-3-Lumimaid-8B-v0.1",
@@ -9530,8 +9542,8 @@
"instruct_type": "mistral"
},
"pricing": {
"prompt": "0.0000009",
"completion": "0.0000009",
"prompt": "0.0000004",
"completion": "0.0000012",
"request": "0",
"image": "0",
"web_search": "0",
@@ -9598,13 +9610,13 @@
"max_tokens",
"temperature",
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"min_p",
"frequency_penalty",
"repetition_penalty",
"top_k",
"stop",
"seed",
"min_p",
"logit_bias",
"response_format"
]
@@ -9825,7 +9837,7 @@
},
"top_provider": {
"context_length": 4096,
"max_completion_tokens": 2048,
"max_completion_tokens": null,
"is_moderated": false
},
"per_request_limits": null,
@@ -10670,7 +10682,9 @@
"logit_bias",
"min_p",
"response_format",
"seed"
"seed",
"logprobs",
"top_logprobs"
]
},
{
@@ -11565,13 +11579,13 @@
"max_tokens",
"temperature",
"top_p",
"stop",
"frequency_penalty",
"presence_penalty",
"seed",
"top_k",
"min_p",
"frequency_penalty",
"repetition_penalty",
"top_k",
"stop",
"seed",
"min_p",
"logit_bias",
"response_format",
"top_a"
+32 -4
View File
@@ -5,13 +5,41 @@ description = "Payment proxy for your LLM endpoint using cashu and nostr."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"aiosqlite>=0.20",
"cashu>=0.16",
"fastapi[standard]>=0.115",
"aiosqlite>=0.20",
"sixty-nuts>=0.0.3",
"sqlmodel>=0.0.24",
"marshmallow<4.0",
"httpx[socks]>=0.25.2",
"greenlet>=3.2.1",
]
[dependency-groups]
dev = ["mypy>=1.15.0", "ruff>=0.11.6", "openai>=1.76.0", "pytest>=8.0.0", "pytest-asyncio>=0.24.0", "httpx>=0.25.2"]
dev = [
"mypy>=1.15.0",
"ruff>=0.11.6",
"openai>=1.76.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"httpx>=0.25.2",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
addopts = [
"-v",
"--tb=short",
"--strict-markers",
"--disable-warnings",
"-p",
"no:warnings",
]
markers = [
"asyncio: marks tests as async (deselect with '-m \"not asyncio\"')",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
]
-17
View File
@@ -1,17 +0,0 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
addopts =
-v
--tb=short
--strict-markers
--disable-warnings
-p no:warnings
markers =
asyncio: marks tests as async (deselect with '-m "not asyncio"')
integration: marks tests as integration tests
unit: marks tests as unit tests
+43 -15
View File
@@ -2,10 +2,10 @@ from typing import Annotated
from fastapi import APIRouter, Header, HTTPException, Depends
from .auth import validate_bearer_key
from .cashu import refund_balance, credit_balance, create_token
from .cashu import refund_balance, credit_balance, WALLET
from .db import ApiKey, AsyncSession, get_session
account_router = APIRouter(prefix="/v1/wallet")
wallet_router = APIRouter(prefix="/v1/wallet")
async def get_key_from_header(
@@ -20,17 +20,24 @@ async def get_key_from_header(
detail="Invalid authorization. Use 'Bearer <cashu-token>' or 'Bearer <api-key>'",
)
@account_router.get("/")
# TODO: remove this endpoint when frontend is updated
@wallet_router.get("/")
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
}
@wallet_router.get("/info")
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
return {
"api_key": "sk-" + key.hashed_key,
"balance": key.balance,
}
@account_router.post("/topup")
async def topup_balance_endpoint(
@wallet_router.post("/topup")
async def topup_wallet_endpoint(
cashu_token: str,
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
@@ -38,18 +45,39 @@ async def topup_balance_endpoint(
return await credit_balance(cashu_token, key, session)
@account_router.post("/refund")
async def refund_balance_endpoint(
@wallet_router.post("/refund")
async def refund_wallet_endpoint(
key: ApiKey = Depends(get_key_from_header),
session: AsyncSession = Depends(get_session),
) -> dict:
remaining_balance = key.balance
remaining_balance_msats = key.balance
if remaining_balance_msats == 0:
raise HTTPException(status_code=400, detail="No balance to refund")
# Perform refund operation first, before modifying balance
if key.refund_address:
await refund_balance(remaining_balance_msats, key, session)
result = {"recipient": key.refund_address, "msats": remaining_balance_msats}
else:
# Convert msats to sats for cashu wallet
remaining_balance_sats = remaining_balance_msats // 1000
if remaining_balance_sats == 0:
raise HTTPException(status_code=400, detail="Balance too small to refund (less than 1 sat)")
token = await WALLET.send(remaining_balance_sats)
result = {"msats": remaining_balance_msats, "recipient": None, "token": token}
# Only after successful refund, zero out the balance
key.balance = 0
session.add(key)
await session.commit()
if key.refund_address:
await refund_balance(remaining_balance, key, session)
return {"recipient": key.refund_address, "msats": remaining_balance}
else:
token = await create_token(remaining_balance)
return {"msats": remaining_balance, "recipient": None, "token": token}
return result
@wallet_router.api_route(
"/{path:path}", methods=["GET", "POST", "PUT", "DELETE"], include_in_schema=False
)
async def wallet_catch_all(path: str):
raise HTTPException(status_code=404, detail="Not found check /docs for available endpoints")
+18 -16
View File
@@ -3,11 +3,14 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from sqlmodel import select
from .cashu import _initialize_wallet
from .db import ApiKey, create_session
from .cashu import WALLET
admin_router = APIRouter(prefix="/admin")
def login_form() -> str:
return """<!DOCTYPE html>
<html>
@@ -76,6 +79,7 @@ def info(content: str) -> str:
</html>
"""
def admin_auth() -> str:
if os.getenv("ADMIN_PASSWORD", "") == "":
return info("Please set a secure ADMIN_PASSWORD= in your ENV variables.")
@@ -83,11 +87,7 @@ def admin_auth() -> str:
return login_form()
from sqlmodel import select
from .db import ApiKey, create_session
async def dashboard(request: Request) -> str:
# fetch cashu / api-key data from database
async with create_session() as session:
result = await session.exec(select(ApiKey))
@@ -95,22 +95,24 @@ async def dashboard(request: Request) -> str:
api_keys_table_rows = []
for key in api_keys:
expiry_time_utc = datetime.fromtimestamp(key.key_expiry_time, tz=timezone.utc) if key.key_expiry_time is not None else None
expiry_time_human_readable = expiry_time_utc.strftime('%Y-%m-%d %H:%M:%S') if expiry_time_utc else ""
expiry_time_utc = (
datetime.fromtimestamp(key.key_expiry_time, tz=timezone.utc)
if key.key_expiry_time is not None
else None
)
expiry_time_human_readable = (
expiry_time_utc.strftime("%Y-%m-%d %H:%M:%S") if expiry_time_utc else ""
)
api_keys_table_rows.append(
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{'{} ({} UTC)'.format(key.key_expiry_time, expiry_time_human_readable) if key.key_expiry_time else key.key_expiry_time}</td></tr>"
)
api_keys_table_rows = "".join(api_keys_table_rows)
# Calculate the total balance of all API keys
total_user_balance = int(sum(key.balance / 1000 for key in api_keys))
# Fetch balance from cashu
wallet = await _initialize_wallet()
wallet_balance = wallet.balance
# calculate owner balance
owner_balance = wallet_balance - total_user_balance
current_balance = (await WALLET.fetch_wallet_state()).balance
owner_balance = current_balance - total_user_balance
return f"""<!DOCTYPE html>
<html>
@@ -132,7 +134,7 @@ async def dashboard(request: Request) -> str:
<h2>Current Cashu Balance</h2>
<p>Your Balance: {owner_balance} sats</p>
<p>The balance is calculated by subtracting the combined user balance from the total Cashu wallet balance.</p>
<p>Total Cashu Balance: {wallet_balance} sats</p>
<p>Total Cashu Balance: {current_balance} sats</p>
<p>User Balance: {total_user_balance} sats</p>
<h2>User's API Keys</h2>
<table>
@@ -144,16 +146,16 @@ async def dashboard(request: Request) -> str:
<th>Refund Address</th>
<th>Refund Time</th>
</tr>
{api_keys_table_rows}
{"".join(api_keys_table_rows)}
</table>
</body>
</html>
"""
@admin_router.get("/", response_class=HTMLResponse)
async def admin(request: Request):
admin_cookie = request.cookies.get("admin_password")
if admin_cookie and admin_cookie == os.getenv("ADMIN_PASSWORD"):
return await dashboard(request)
return admin_auth()
+55 -32
View File
@@ -7,7 +7,7 @@ from typing import Optional
from fastapi import HTTPException, Request
from .cashu import credit_balance, pay_out_with_new_session
from .cashu import credit_balance, pay_out
from .db import ApiKey, AsyncSession
from .models import MODELS
@@ -23,7 +23,12 @@ COST_PER_1K_OUTPUT_TOKENS = (
MODEL_BASED_PRICING = os.environ.get("MODEL_BASED_PRICING", "false").lower() == "true"
async def validate_bearer_key(bearer_key: str, session: AsyncSession, refund_address: Optional[str] = None, key_expiry_time: Optional[int] = None) -> ApiKey:
async def validate_bearer_key(
bearer_key: str,
session: AsyncSession,
refund_address: Optional[str] = None,
key_expiry_time: Optional[int] = None,
) -> ApiKey:
"""
Validates the provided API key using SQLModel.
If it's a cashu key, it redeems it and stores its hash and balance.
@@ -31,30 +36,43 @@ async def validate_bearer_key(bearer_key: str, session: AsyncSession, refund_add
"""
if not bearer_key:
raise HTTPException(
status_code=401,
status_code=401,
detail={
"error": {
"message": "API key or Cashu token required",
"type": "invalid_request_error",
"code": "missing_api_key"
"code": "missing_api_key",
}
}
},
)
if bearer_key.startswith("sk-"):
if existing_key := await session.get(ApiKey, bearer_key[3:]):
existing_key.key_expiry_time, existing_key.refund_address = key_expiry_time, refund_address
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
if refund_address is not None:
existing_key.refund_address = refund_address
return existing_key
if bearer_key.startswith("cashu"):
try:
hashed_key = hashlib.sha256(bearer_key.encode()).hexdigest()
if existing_key := await session.get(ApiKey, hashed_key):
existing_key.key_expiry_time, existing_key.refund_address = key_expiry_time, refund_address
if key_expiry_time is not None:
existing_key.key_expiry_time = key_expiry_time
if refund_address is not None:
existing_key.refund_address = refund_address
return existing_key
new_key = ApiKey(hashed_key=hashed_key, balance=0, refund_address = refund_address, key_expiry_time = key_expiry_time)
await credit_balance(bearer_key, new_key, session) #TODO: see cashu.py "_initialize_wallet"
new_key = ApiKey(
hashed_key=hashed_key,
balance=0,
refund_address=refund_address,
key_expiry_time=key_expiry_time,
)
await credit_balance(
bearer_key, new_key, session
) # TODO: see cashu.py "_initialize_wallet"
await session.refresh(new_key)
return new_key
except Exception as e:
@@ -65,9 +83,9 @@ async def validate_bearer_key(bearer_key: str, session: AsyncSession, refund_add
"error": {
"message": f"Invalid or expired Cashu key: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_api_key"
"code": "invalid_api_key",
}
}
},
)
raise HTTPException(
status_code=401,
@@ -75,18 +93,23 @@ async def validate_bearer_key(bearer_key: str, session: AsyncSession, refund_add
"error": {
"message": "Invalid API key",
"type": "invalid_request_error",
"code": "invalid_api_key"
"code": "invalid_api_key",
}
}
},
)
async def pay_for_request(key: ApiKey, session: AsyncSession, request: Request | None, request_body: bytes | None = None) -> None:
async def pay_for_request(
key: ApiKey,
session: AsyncSession,
request: Request | None,
request_body: bytes | None = None,
) -> None:
if MODEL_BASED_PRICING and os.path.exists("models.json"):
if request_body:
body = json.loads(request_body)
else:
body = await request.json()
body = await request.json() # type: ignore
if request_model := body.get("model"):
if request_model not in [model.id for model in MODELS]:
raise HTTPException(
@@ -95,21 +118,21 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, request: Request |
"error": {
"message": f"Invalid model: {request_model}",
"type": "invalid_request_error",
"code": "model_not_found"
"code": "model_not_found",
}
}
},
)
model = next(model for model in MODELS if model.id == request_model)
if key.balance < model.sats_pricing.max_cost * 1000:
if key.balance < model.sats_pricing.max_cost * 1000: # type: ignore
raise HTTPException(
status_code=413,
detail={
"error": {
"message": f"This model requires a minimum balance of {model.sats_pricing.max_cost} sats",
"message": f"This model requires a minimum balance of {model.sats_pricing.max_cost} sats", # type: ignore
"type": "insufficient_quota",
"code": "insufficient_balance"
"code": "insufficient_balance",
}
}
},
)
if key.balance < COST_PER_REQUEST:
@@ -119,9 +142,9 @@ async def pay_for_request(key: ApiKey, session: AsyncSession, request: Request |
"error": {
"message": f"Insufficient balance: {COST_PER_REQUEST} mSats required. {key.balance} available.",
"type": "insufficient_quota",
"code": "insufficient_balance"
"code": "insufficient_balance",
}
}
},
)
# Charge the base cost for the request
@@ -141,7 +164,7 @@ async def adjust_payment_for_tokens(
This is called after the initial payment and the upstream request is complete.
Returns cost data to be included in the response.
"""
cost_data = {
cost_data: dict = {
"base_msats": COST_PER_REQUEST,
"input_msats": 0,
"output_msats": 0,
@@ -166,9 +189,9 @@ async def adjust_payment_for_tokens(
"error": {
"message": f"Invalid model in response: {response_model}",
"type": "invalid_request_error",
"code": "model_not_found"
"code": "model_not_found",
}
}
},
)
model = next(model for model in MODELS if model.id == response_model)
if model.sats_pricing is None:
@@ -178,13 +201,13 @@ async def adjust_payment_for_tokens(
"error": {
"message": "Model pricing not defined",
"type": "invalid_request_error",
"code": "pricing_not_found"
"code": "pricing_not_found",
}
}
},
)
MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000
MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000
MSATS_PER_1K_INPUT_TOKENS = model.sats_pricing.prompt * 1_000_000 # type: ignore
MSATS_PER_1K_OUTPUT_TOKENS = model.sats_pricing.completion * 1_000_000 # type: ignore
if not (MSATS_PER_1K_OUTPUT_TOKENS and MSATS_PER_1K_INPUT_TOKENS):
# If no token pricing is configured, just return base cost
@@ -233,6 +256,6 @@ async def adjust_payment_for_tokens(
session.add(key)
await session.commit()
asyncio.create_task(pay_out_with_new_session())
asyncio.create_task(pay_out())
return cost_data
+82 -322
View File
@@ -1,151 +1,68 @@
import os
import httpx
import asyncio
import time
from cashu.core.base import Token # type: ignore
from cashu.wallet.wallet import Wallet # type: ignore
from cashu.wallet.helpers import deserialize_token_from_string, receive # type: ignore
from sixty_nuts import Wallet
from sqlmodel import select, func, col
from .db import ApiKey, AsyncSession, get_session
RECEIVE_LN_ADDRESS = os.environ["RECEIVE_LN_ADDRESS"]
MINT = os.environ.get("MINT", "https://mint.minibits.cash/Bitcoin")
MINIMUM_PAYOUT = int(os.environ.get("MINIMUM_PAYOUT", 100))
REFUND_PROCESSING_INTERVAL = int(os.environ.get("REFUND_PROCESSING_INTERVAL", 3600))
DEV_LN_ADDRESS = "routstr@minibits.cash"
DEVS_DONATION_RATE = float(os.environ.get("DEVS_DONATION_RATE", 0.021)) # 2.1%
WALLET = None
NSEC = os.environ["NSEC"] # Nostr private key for the wallet
#TODO
# This causes problems when users send tokens from other mints
# WALLET is already set so it returns the specified wallet, but this wallet does not know the keyset of the token
async def _initialize_wallet(mint_url: str | None = None) -> Wallet:
"""Initializes and loads a Cashu wallet."""
WALLET = Wallet(nsec=NSEC, mint_urls=[MINT])
async def init_wallet():
global WALLET
if WALLET is not None:
return WALLET
if mint_url is None:
mint_url = MINT
wallet = await Wallet.with_db(
mint_url,
db=".",
load_all_keysets=True,
unit="sat", # todo change to msat
)
print(f"initialized cashu wallet at mint {mint_url}", flush=True)
await wallet.load_mint_info()
await wallet.load_mint_keysets()
if not hasattr(wallet, "keyset_id") or wallet.keyset_id is None:
await wallet.activate_keyset()
await wallet.load_proofs(reload=True)
WALLET = wallet
return wallet
async def _handle_token_receive(wallet: Wallet, token_obj: Token) -> int:
"""Receives a token and returns the amount received."""
initial_balance = wallet.available_balance
await receive(wallet, token_obj)
await wallet.load_proofs(reload=True)
final_balance = wallet.available_balance
amount_received = final_balance - initial_balance
if amount_received <= 0:
raise ValueError("Token contained no value.")
return amount_received * 1000
async def _get_lnurl_invoice(callback_url: str, amount_msat: int) -> tuple[str, dict]:
"""Requests an invoice from the LNURL callback URL."""
async with httpx.AsyncClient() as client:
response = await client.get(
callback_url,
params={"amount": amount_msat},
follow_redirects=True,
)
response.raise_for_status() # Raise exception for non-2xx status codes
invoice_data = response.json()
if "pr" not in invoice_data:
raise ValueError(f"Invalid LNURL invoice response: {invoice_data}")
return invoice_data["pr"], invoice_data
async def _pay_invoice_with_cashu(
wallet: Wallet, bolt11_invoice: str, amount_to_send_msat: int
) -> int:
"""Pays a BOLT11 invoice using Cashu proofs via melt."""
amount_to_send_msat = amount_to_send_msat // 1000
quote = await wallet.melt_quote(bolt11_invoice, amount_to_send_msat)
proofs_to_melt, _ = await wallet.select_to_send(
wallet.proofs, quote.amount + quote.fee_reserve
)
WALLET = await Wallet.create(nsec=NSEC, mint_urls=[MINT])
# Debugging Cashu Proofs
#print(f"Proofs to melt: {proofs_to_melt}")
async def close_wallet():
global WALLET
await WALLET.aclose()
_ = await wallet.melt(
proofs_to_melt, bolt11_invoice, quote.fee_reserve, quote.quote
)
return quote.amount
async def pay_out_with_new_session() -> None:
"""
Wrapper for pay_out that creates its own database session.
This prevents database connection conflicts when called as a background task.
"""
from .db import create_session
try:
async with create_session() as session:
await pay_out(session)
except Exception as e:
print(f"Error in pay_out_with_new_session: {e}")
async def pay_out(session: AsyncSession) -> None:
async def pay_out() -> None:
"""
Calculates the pay-out amount based on the spent balance, profit, and donation rate.
"""
try:
balance = (
await session.exec(
select(func.sum(col(ApiKey.balance))).where(ApiKey.balance > 0)
)
).one()
if balance is None or balance == 0:
# No balance to pay out - this is OK, not an error
return
user_balance_sats = balance // 1000 # Convert msats to sats
wallet = await _initialize_wallet()
wallet_balance_sats = wallet.available_balance # Already in sats
from .db import create_session
# Handle edge cases more gracefully
if wallet_balance_sats < user_balance_sats:
print(f"Warning: Wallet balance ({wallet_balance_sats} sats) is less than user balance ({user_balance_sats} sats). Skipping payout.")
return
async with create_session() as session:
balance = (
await session.exec(
select(func.sum(col(ApiKey.balance))).where(ApiKey.balance > 0)
)
).one()
if balance is None or balance == 0:
# No balance to pay out - this is OK, not an error
return
if (revenue := wallet_balance_sats - user_balance_sats) <= MINIMUM_PAYOUT:
# Not enough revenue yet - this is OK
return
user_balance_sats = balance // 1000
state = await WALLET.fetch_wallet_state()
wallet_balance_sats = state.balance
devs_donation = int(revenue * DEVS_DONATION_RATE)
owners_draw = revenue - devs_donation
# Handle edge cases more gracefully
if wallet_balance_sats < user_balance_sats:
print(
f"Warning: Wallet balance ({wallet_balance_sats} sats) is less than user balance ({user_balance_sats} sats). Skipping payout."
)
return
# Send payouts
print(f"Sending {owners_draw} sats to {RECEIVE_LN_ADDRESS}")
await send_to_lnurl(wallet, RECEIVE_LN_ADDRESS, owners_draw * 1000) # Convert to msats
print(f"Sending {devs_donation} sats to {DEV_LN_ADDRESS}")
await send_to_lnurl(
wallet,
DEV_LN_ADDRESS,
devs_donation * 1000, # Convert to msats
)
if (revenue := wallet_balance_sats - user_balance_sats) <= MINIMUM_PAYOUT:
# Not enough revenue yet - this is OK
return
devs_donation = int(revenue * DEVS_DONATION_RATE)
owners_draw = revenue - devs_donation
# Send payouts
await WALLET.send_to_lnurl(RECEIVE_LN_ADDRESS, owners_draw)
await WALLET.send_to_lnurl(DEV_LN_ADDRESS, devs_donation)
except Exception as e:
# Log the error but don't crash - payouts can be retried later
@@ -153,21 +70,14 @@ async def pay_out(session: AsyncSession) -> None:
async def credit_balance(cashu_token: str, key: ApiKey, session: AsyncSession) -> int:
token_obj: Token = deserialize_token_from_string(cashu_token)
wallet: Wallet = await _initialize_wallet(token_obj.mint)
if token_obj.mint == MINT:
# crediting a token created using the same mint as specified in .env
print("Received a token from the same mint", flush=True)
amount_msats = await _handle_token_receive(wallet, token_obj)
key.balance += amount_msats
session.add(key)
await session.commit()
return amount_msats
else:
# crediting a token created using a different mint as specified in .env
print("Received a token from a different mint", flush=True)
#TODO This fails, and needs to be fixed
state_before = await WALLET.fetch_wallet_state()
await WALLET.redeem(cashu_token)
state_after = await WALLET.fetch_wallet_state()
amount = (state_after.balance - state_before.balance) * 1000
key.balance += amount
session.add(key)
await session.commit()
return amount
async def check_for_refunds() -> None:
@@ -177,7 +87,6 @@ async def check_for_refunds() -> None:
Raises:
Exception: If an error occurs during the refund check process.
"""
# Setting REFUND_PROCESSING_INTERVAL to 0 disables it
if REFUND_PROCESSING_INTERVAL == 0:
print("Automatic refund processing is disabled.")
@@ -190,201 +99,52 @@ async def check_for_refunds() -> None:
keys = result.all()
current_time = int(time.time())
for key in keys:
if(key.balance > 0 and key.refund_address and key.key_expiry_time and key.key_expiry_time < current_time):
print(f" DEBUG Refunding key {key.hashed_key[:3] + '[...]' + key.hashed_key[-3:]}, Current Time: {current_time}, Expirary Time: {key.key_expiry_time}", flush = True)
if (
key.balance > 0
and key.refund_address
and key.key_expiry_time
and key.key_expiry_time < current_time
):
print(
f" DEBUG Refunding key {key.hashed_key[:3] + '[...]' + key.hashed_key[-3:]}, Current Time: {current_time}, Expirary Time: {key.key_expiry_time}",
flush=True,
)
await refund_balance(key.balance, key, session)
# Sleep for the specified interval before checking again
await asyncio.sleep(REFUND_PROCESSING_INTERVAL)
await asyncio.sleep(REFUND_PROCESSING_INTERVAL)
except Exception as e:
print(f"Error during refund check: {e}")
async def refund_balance(amount: int, key: ApiKey, session: AsyncSession) -> int:
"""
Refunds the specified amount from an API key's balance to the key's refund address.
Args:
amount (int): The amount to refund in millisatoshis.
key (ApiKey): The API key object containing balance and refund address.
session (AsyncSession): The database session for committing changes.
Returns:
int: The amount in millisatoshis that was successfully sent.
Raises:
ValueError: If balance is insufficient or refund address is not set.
"""
wallet = await _initialize_wallet()
if key.balance < amount:
async def refund_balance(amount_msats: int, key: ApiKey, session: AsyncSession) -> int:
if key.balance < amount_msats:
raise ValueError("Insufficient balance.")
if amount <= 0:
amount = key.balance
key.balance -= amount
if amount_msats <= 0:
amount_msats = key.balance
# Convert msats to sats for cashu wallet
amount_sats = amount_msats // 1000
if amount_sats == 0:
raise ValueError("Amount too small to refund (less than 1 sat)")
key.balance -= amount_msats
session.add(key)
await session.commit()
if key.refund_address is None:
raise ValueError("Refund address not set.")
return await send_to_lnurl(wallet, key.refund_address, amount_msat=amount)
async def create_token(
amount_msats: int, mint: str = MINT
) -> str:
wallet = await _initialize_wallet(mint)
balance = wallet.available_balance
amount_sats = amount_msats // 1000
if balance < amount_sats:
raise ValueError("Insufficient balance on mint.")
print(balance, amount_sats)
if balance > amount_sats:
print("splitting")
_, send_proofs = await wallet.split(wallet.proofs, amount_sats)
else:
print("no splitting")
send_proofs = wallet.proofs
token = await wallet._make_tokenv4(send_proofs)
return token.serialize()
return await WALLET.send_to_lnurl(
key.refund_address,
amount=amount_sats,
)
async def redeem(cashu_token: str, lnurl: str) -> int:
"""
Redeems a Cashu token and sends the amount to an LNURL address.
Args:
cashu_token: The Cashu token string (starting with "cashuA...").
lnurl: The LNURL string (can be bech32, user@host, or direct URL).
Returns:
The amount in millisatoshis that was successfully sent.
Raises:
Exception: If any step of the process fails (token receive, LNURL fetch, invoice payment).
"""
token_obj: Token = deserialize_token_from_string(cashu_token)
wallet: Wallet = await _initialize_wallet(token_obj.mint)
amount_received = await _handle_token_receive(wallet, token_obj)
# if USE_BALANCE_ON_INVALID_TOKEN:
# amount_received = wallet.available_balance
return await send_to_lnurl(wallet, lnurl, amount_received)
async def send_to_lnurl(wallet: Wallet, lnurl: str, amount_msat: int) -> int:
"""
Sends funds from a Cashu wallet to an LNURL address.
Args:
wallet: The initialized Cashu wallet with available balance.
lnurl: The LNURL string (can be bech32, user@host, or direct URL).
amount_msat: The amount in millisatoshis to send.
Returns:
The amount in millisatoshis that was successfully sent.
Raises:
ValueError: If amount is outside LNURL limits or other validation errors.
Exception: If LNURL fetch or invoice payment fails.
"""
print(f"Sending {amount_msat / 1000} sat to {lnurl}")
callback_url, min_sendable, max_sendable = await get_lnurl_data(lnurl)
if not (min_sendable <= amount_msat <= max_sendable):
raise ValueError(
f"Amount {amount_msat / 1000} sat is outside LNURL limits "
f"({min_sendable / 1000} - {max_sendable / 1000} sat)."
)
# subtract estimated fees
# TODO: Is a static fee calculation working well?
# moving the 2000 and 0.01 to optional enviroment variables might give more control to users
amount_to_send = amount_msat - int(max(2000, amount_msat * 0.01))
print(f" DEBUG Trying to pay {amount_to_send} msats to {lnurl}, with Wallet balance = {wallet.balance}", flush = True)
# Note: We pass amount_msat directly. The actual amount paid might be adjusted
# slightly by the melt quote based on the invoice details.
bolt11_invoice, _ = await _get_lnurl_invoice(callback_url, amount_to_send)
# Conversion to Sats (/ 1000) necessary for cashu payments
amount_paid = await _pay_invoice_with_cashu(wallet, bolt11_invoice, amount_to_send / 1000)
print(f" DEBUG {amount_paid} sats paid to lnurl", flush=True)
return amount_paid
async def get_lnurl_data(lnurl: str) -> tuple[str, int, int]:
"""
Fetches LNURL payRequest data (callback URL, min/max sendable amounts).
Handles lightning:, user@host, bech32 lnurl, and direct HTTPS URL formats.
"""
url: str
if lnurl.startswith("lightning:"):
lnurl = lnurl[10:]
if "@" in lnurl and len(lnurl.split("@")) == 2:
user, host = lnurl.split("@")
url = f"https://{host}/.well-known/lnurlp/{user}"
elif lnurl.lower().startswith("lnurl"):
try:
# Optional import for environments where bech32 might not be present initially
from bech32 import bech32_decode, convertbits # type: ignore
hrp, data = bech32_decode(lnurl)
if data is None:
raise ValueError("Invalid bech32 data in LNURL")
decoded_data = convertbits(data, 5, 8, False)
if decoded_data is None:
raise ValueError("Failed to convert LNURL bits")
url = bytes(decoded_data).decode("utf-8")
except ImportError:
raise ImportError("bech32 library is required for LNURL bech32 decoding.")
except Exception as e:
raise ValueError(f"Failed to decode LNURL: {e}") from e
else:
# Assume it's a direct URL
if not lnurl.startswith("https://"):
# Basic check, could be improved
raise ValueError("Direct LNURL must use HTTPS")
url = lnurl
async with httpx.AsyncClient() as client:
response = await client.get(url, follow_redirects=True, timeout=10)
response.raise_for_status() # Raises HTTPStatusError for 4xx/5xx
lnurl_data: dict = response.json()
if lnurl_data.get("tag") != "payRequest" or not isinstance(
lnurl_data.get("callback"), str
):
raise ValueError(f"Invalid LNURL payRequest data: {lnurl_data}")
callback_url: str = lnurl_data["callback"]
# LNURL spec defaults (in millisatoshis)
min_sendable: int = lnurl_data.get("minSendable", 1000)
max_sendable: int = lnurl_data.get("maxSendable", 1000000000) # Default 1000 BTC
return callback_url, min_sendable, max_sendable
if __name__ == "__main__":
import asyncio
# Example usage: Replace with your actual LNURL and Token
lnurl = "user@walletofsatoshi.com" # Replace
# A potentially spent token for testing fallback logic
cashu_token = "cashuBpGF0gaJhaUg..."
# Example: Set USE_BALANCE_ON_INVALID_TOKEN = False to test non-fallback behavior
# USE_BALANCE_ON_INVALID_TOKEN = True
async def main() -> None:
# Removed try-except block, script will crash on error
print(f"Attempting to redeem token and pay LNURL: {lnurl}")
amount_sent = await redeem(cashu_token, lnurl)
print(f"✅ Successfully sent {amount_sent / 1000} sat ({amount_sent} msat).")
# Removed try-except block for KeyboardInterrupt
asyncio.run(main())
state_before = await WALLET.fetch_wallet_state()
await WALLET.redeem(cashu_token)
state_after = await WALLET.fetch_wallet_state()
amount = state_after.balance - state_before.balance
await WALLET.send_to_lnurl(lnurl, amount=amount)
return amount
+2 -2
View File
@@ -49,7 +49,7 @@ async def query_nostr_relay_with_search(
"limit": limit,
"#p": [npub], # Posts that tag this pubkey
}
except:
except Exception:
# If conversion fails, try regular search
filter_obj = {
"kinds": kinds,
@@ -125,7 +125,7 @@ async def fetch_onion(provider: str) -> dict:
# Configure httpx to use Tor SOCKS5 proxy
async with httpx.AsyncClient(
proxies={"http://": tor_proxy, "https://": tor_proxy},
proxies={"http://": tor_proxy, "https://": tor_proxy}, # type: ignore
timeout=httpx.Timeout(30.0),
follow_redirects=True,
) as client:
+17 -12
View File
@@ -1,4 +1,5 @@
import asyncio
from contextlib import asynccontextmanager
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -6,19 +7,31 @@ from fastapi.middleware.cors import CORSMiddleware
from .db import init_db
from .admin import admin_router
from .proxy import proxy_router
from .account import account_router
from .cashu import _initialize_wallet, check_for_refunds
from .account import wallet_router
from .models import MODELS, update_sats_pricing
from .cashu import check_for_refunds, init_wallet, close_wallet
from .discovery import providers_router
__version__ = "0.0.1"
@asynccontextmanager
async def lifespan(_: FastAPI):
await init_db()
await init_wallet()
asyncio.create_task(update_sats_pricing())
asyncio.create_task(check_for_refunds())
yield
await close_wallet()
app = FastAPI(
version=__version__,
title=os.environ.get("NAME", "ARoutstrNode" + __version__),
description=os.environ.get("DESCRIPTION", "A Routstr Node"),
contact={"name": os.environ.get("NAME", ""), "npub": os.environ.get("NPUB", "")},
lifespan=lifespan,
)
# Configure CORS
@@ -46,14 +59,6 @@ async def info():
app.include_router(admin_router)
app.include_router(account_router)
app.include_router(wallet_router)
app.include_router(providers_router)
app.include_router(proxy_router)
@app.on_event("startup")
async def startup_event():
await init_db()
await _initialize_wallet()
asyncio.create_task(update_sats_pricing())
asyncio.create_task(check_for_refunds())
+57 -46
View File
@@ -1,4 +1,3 @@
import asyncio
import os
import json
from fastapi import APIRouter, Request, BackgroundTasks, Depends
@@ -6,7 +5,7 @@ from fastapi.responses import Response, StreamingResponse
import httpx
import re
from router.cashu import pay_out_with_new_session
from .cashu import pay_out
from .auth import validate_bearer_key, pay_for_request, adjust_payment_for_tokens
from .db import AsyncSession, get_session
@@ -18,7 +17,7 @@ proxy_router = APIRouter()
@proxy_router.api_route(
"/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH"]
"/{path:path}", methods=["GET", "POST"]
)
async def proxy(
request: Request, path: str, session: AsyncSession = Depends(get_session)
@@ -31,22 +30,27 @@ async def proxy(
# Validate key_expiry_time header
if key_expiry_time:
try:
key_expiry_time = int(key_expiry_time)
key_expiry_time = int(key_expiry_time) # type: ignore
except ValueError:
return Response(
content="Invalid Key-Expiry-Time: must be a valid Unix timestamp",
status_code=400,
)
if(not refund_address):
if not refund_address:
return Response(
content=f"Error: Refund-LNURL header required when using Key-Expiry-Time",
content="Error: Refund-LNURL header required when using Key-Expiry-Time",
status_code=400,
)
else:
key_expiry_time = None
key = await validate_bearer_key(bearer_key, session, refund_address, key_expiry_time)
key = await validate_bearer_key(
bearer_key,
session,
refund_address,
key_expiry_time, # type: ignore
)
# Pre-validate JSON for requests that require it
request_body = None
if request.method in ["POST", "PUT", "PATCH"] and path.endswith("chat/completions"):
@@ -57,29 +61,33 @@ async def proxy(
json.loads(request_body)
except json.JSONDecodeError as e:
return Response(
content=json.dumps({
"error": {
"message": f"Invalid JSON in request body: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_json"
content=json.dumps(
{
"error": {
"message": f"Invalid JSON in request body: {str(e)}",
"type": "invalid_request_error",
"code": "invalid_json",
}
}
}),
),
status_code=400,
media_type="application/json"
media_type="application/json",
)
except Exception as e:
except Exception:
return Response(
content=json.dumps({
"error": {
"message": "Error reading request body",
"type": "invalid_request_error",
"code": "request_error"
content=json.dumps(
{
"error": {
"message": "Error reading request body",
"type": "invalid_request_error",
"code": "request_error",
}
}
}),
),
status_code=400,
media_type="application/json"
media_type="application/json",
)
await pay_for_request(key, session, request, request_body)
# Prepare headers, removing sensitive/problematic ones
@@ -102,7 +110,7 @@ async def proxy(
url = f"{UPSTREAM_BASE_URL}/{path}"
client = httpx.AsyncClient(
transport=httpx.AsyncHTTPTransport(retries=1),
timeout=None # No timeout - requests can take as long as needed
timeout=None, # No timeout - requests can take as long as needed
)
try:
@@ -141,7 +149,6 @@ async def proxy(
async def stream_with_cost():
# Store all chunks to analyze
stored_chunks = []
usage_data_found = False
async for chunk in response.aiter_bytes():
# Store chunk for later analysis
@@ -182,11 +189,10 @@ async def proxy(
# Format as SSE and yield
cost_json = json.dumps({"cost": cost_data})
yield f"data: {cost_json}\n\n".encode()
usage_data_found = True
break
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Error processing streaming response for cost: {e}")
@@ -234,7 +240,7 @@ async def proxy(
background_tasks = BackgroundTasks()
background_tasks.add_task(response.aclose)
background_tasks.add_task(client.aclose)
background_tasks.add_task(pay_out_with_new_session)
background_tasks.add_task(pay_out)
return StreamingResponse(
response.aiter_bytes(),
@@ -252,7 +258,7 @@ async def proxy(
f"Request details: method={request.method}, url={url}, headers={headers}, "
f"path={path}, query_params={dict(request.query_params)}"
)
# Provide more specific error messages based on the error type
if isinstance(exc, httpx.ConnectError):
error_message = "Unable to connect to upstream service"
@@ -262,21 +268,24 @@ async def proxy(
error_message = "Network error while connecting to upstream service"
else:
error_message = f"Error connecting to upstream service: {error_type}"
return Response(
content=json.dumps({
"error": {
"message": error_message,
"type": "upstream_error",
"code": 502
content=json.dumps(
{
"error": {
"message": error_message,
"type": "upstream_error",
"code": 502,
}
}
}),
),
status_code=502,
media_type="application/json"
media_type="application/json",
)
except Exception as exc:
await client.aclose()
import traceback
tb = traceback.format_exc()
print(
f"Unexpected error: {exc}\n"
@@ -285,13 +294,15 @@ async def proxy(
f"Traceback:\n{tb}"
)
return Response(
content=json.dumps({
"error": {
"message": "An unexpected server error occurred",
"type": "internal_error",
"code": 500
content=json.dumps(
{
"error": {
"message": "An unexpected server error occurred",
"type": "internal_error",
"code": 500,
}
}
}),
),
status_code=500,
media_type="application/json"
media_type="application/json",
)
+85 -52
View File
@@ -2,12 +2,12 @@ import asyncio
import os
import pytest
import pytest_asyncio
from typing import AsyncGenerator
from typing import AsyncGenerator, Generator
from fastapi.testclient import TestClient
from httpx import AsyncClient, ASGITransport
from sqlmodel import SQLModel
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from unittest.mock import patch, MagicMock, AsyncMock
# Save original environment variables
@@ -28,24 +28,35 @@ TEST_ENV = {
"COST_PER_REQUEST": "1",
"COST_PER_1K_INPUT_TOKENS": "0",
"COST_PER_1K_OUTPUT_TOKENS": "0",
"MODEL_BASED_PRICING": "false"
"MODEL_BASED_PRICING": "false",
"NSEC": "test-nsec-key", # Added required NSEC env var
}
# Apply test environment
os.environ.update(TEST_ENV)
# Mock the cashu wallet initialization before importing
with patch("router.cashu._initialize_wallet") as mock_init_wallet:
# Mock the Wallet class from sixty_nuts before importing the app
with patch("sixty_nuts.Wallet") as mock_wallet_class:
# Create a mock wallet instance
mock_wallet = AsyncMock()
mock_wallet.available_balance = 1000
mock_wallet.proofs = []
mock_wallet.split = AsyncMock(return_value=([], []))
mock_init_wallet.return_value = mock_wallet
with patch("router.cashu.WALLET", mock_wallet):
from router.main import app
from router.db import get_session
from router.models import MODELS
mock_wallet.__aenter__ = AsyncMock(return_value=mock_wallet)
mock_wallet.__aexit__ = AsyncMock(return_value=None)
# Mock wallet state
mock_state = MagicMock()
mock_state.balance = 1000 # Balance in sats
mock_wallet.fetch_wallet_state = AsyncMock(return_value=mock_state)
# Mock other wallet methods
mock_wallet.send_to_lnurl = AsyncMock(return_value=100)
mock_wallet.redeem = AsyncMock(return_value=None)
mock_wallet.send = AsyncMock(return_value="cashu:token123")
# Make the Wallet class return our mock when instantiated
mock_wallet_class.return_value = mock_wallet
from router.main import app
from router.db import get_session
@pytest.fixture(scope="session")
@@ -64,68 +75,90 @@ async def test_engine():
echo=False,
future=True,
)
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def test_session(test_engine) -> AsyncSession:
async def test_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
"""Create a test database session."""
async_session = sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
from sqlmodel.ext.asyncio.session import AsyncSession as SqlModelAsyncSession
async with SqlModelAsyncSession(test_engine, expire_on_commit=False) as session:
yield session
@pytest.fixture
def test_client() -> TestClient:
def test_client() -> Generator[TestClient, None, None]:
"""Create a test client for the FastAPI app."""
with patch.dict(os.environ, TEST_ENV, clear=True):
with patch("router.cashu._initialize_wallet") as mock_init:
with patch("sixty_nuts.Wallet") as mock_wallet_class:
# Create a mock wallet instance
mock_wallet = AsyncMock()
mock_wallet.available_balance = 1000
mock_wallet.proofs = []
mock_wallet.split = AsyncMock(return_value=([], []))
mock_init.return_value = mock_wallet
mock_wallet.__aenter__ = AsyncMock(return_value=mock_wallet)
mock_wallet.__aexit__ = AsyncMock(return_value=None)
# Mock wallet state
mock_state = MagicMock()
mock_state.balance = 1000 # Balance in sats
mock_wallet.fetch_wallet_state = AsyncMock(return_value=mock_state)
# Mock other wallet methods
mock_wallet.send_to_lnurl = AsyncMock(return_value=100)
mock_wallet.redeem = AsyncMock(return_value=None)
mock_wallet.send = AsyncMock(return_value="cashu:token123")
# Make the Wallet class return our mock when instantiated
mock_wallet_class.return_value = mock_wallet
with patch("router.models.update_sats_pricing") as mock_update:
mock_update.return_value = None
yield TestClient(app)
@pytest_asyncio.fixture
async def async_client(test_session) -> AsyncClient:
async def async_client(test_session) -> AsyncGenerator[AsyncClient, None]:
"""Create an async test client with dependency overrides."""
async def override_get_session():
yield test_session
app.dependency_overrides[get_session] = override_get_session
# Mock startup tasks
with patch.dict(os.environ, TEST_ENV, clear=True):
with patch("router.cashu._initialize_wallet") as mock_init:
with patch("sixty_nuts.Wallet") as mock_wallet_class:
# Create a mock wallet instance
mock_wallet = AsyncMock()
mock_wallet.available_balance = 1000
mock_wallet.proofs = []
mock_wallet.split = AsyncMock(return_value=([], []))
mock_init.return_value = mock_wallet
mock_wallet.__aenter__ = AsyncMock(return_value=mock_wallet)
mock_wallet.__aexit__ = AsyncMock(return_value=None)
# Mock wallet state
mock_state = MagicMock()
mock_state.balance = 1000 # Balance in sats
mock_wallet.fetch_wallet_state = AsyncMock(return_value=mock_state)
# Mock other wallet methods
mock_wallet.send_to_lnurl = AsyncMock(return_value=100)
mock_wallet.redeem = AsyncMock(return_value=None)
mock_wallet.send = AsyncMock(return_value="cashu:token123")
# Make the Wallet class return our mock when instantiated
mock_wallet_class.return_value = mock_wallet
with patch("router.models.update_sats_pricing") as mock_update:
mock_update.return_value = None
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
transport=ASGITransport(app=app), base_url="http://test"
) as client:
yield client
app.dependency_overrides.clear()
@@ -144,7 +177,7 @@ def mock_models():
"input_modalities": ["text"],
"output_modalities": ["text"],
"tokenizer": "cl100k_base",
"instruct_type": "none"
"instruct_type": "none",
},
"pricing": {
"prompt": 0.03,
@@ -152,13 +185,13 @@ def mock_models():
"request": 0.001,
"image": 0.0,
"web_search": 0.0,
"internal_reasoning": 0.0
"internal_reasoning": 0.0,
},
"top_provider": {
"context_length": 8192,
"max_completion_tokens": 4096,
"is_moderated": False
}
"is_moderated": False,
},
}
]
@@ -170,12 +203,12 @@ def cleanup():
# Restore original environment carefully
current_keys = set(os.environ.keys())
original_keys = set(ORIGINAL_ENV.keys())
# Remove keys that weren't in original
for key in current_keys - original_keys:
if key != 'PYTEST_CURRENT_TEST': # Don't touch pytest's own variables
if key != "PYTEST_CURRENT_TEST": # Don't touch pytest's own variables
os.environ.pop(key, None)
# Restore original values
for key, value in ORIGINAL_ENV.items():
os.environ[key] = value
os.environ[key] = value
+70 -81
View File
@@ -2,7 +2,7 @@ import pytest
import pytest_asyncio
import hashlib
import uuid
from unittest.mock import patch, AsyncMock, MagicMock
from unittest.mock import patch, AsyncMock
from httpx import AsyncClient
from router.db import ApiKey, AsyncSession
@@ -18,36 +18,34 @@ async def test_api_key(test_session: AsyncSession) -> ApiKey:
# Use unique key for each test
unique_id = str(uuid.uuid4())[:8]
api_key = f"test-api-key-{unique_id}"
key = ApiKey(
hashed_key=api_key,
balance=1000000, # 1000 sats in msats
refund_address="test@lightning.address",
total_spent=0,
total_requests=0
total_requests=0,
)
test_session.add(key)
await test_session.commit()
await test_session.refresh(key)
return key
@pytest.mark.asyncio
async def test_account_info_with_valid_key(
async_client: AsyncClient,
test_api_key: ApiKey
async_client: AsyncClient, test_api_key: ApiKey
):
"""Test getting account info with a valid API key."""
response = await async_client.get(
"/v1/wallet/",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"}
"/v1/wallet/", headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"}
)
assert response.status_code == 200
data = response.json()
assert data["api_key"] == f"sk-{test_api_key.hashed_key}"
assert data["balance"] == 1000000
@@ -56,7 +54,7 @@ async def test_account_info_with_valid_key(
async def test_account_info_without_auth(async_client: AsyncClient):
"""Test that account info requires authentication."""
response = await async_client.get("/v1/wallet/")
assert response.status_code == 422 # Missing required header
@@ -64,153 +62,144 @@ async def test_account_info_without_auth(async_client: AsyncClient):
async def test_account_info_with_invalid_key(async_client: AsyncClient):
"""Test account info with an invalid API key."""
response = await async_client.get(
"/v1/wallet/",
headers={"Authorization": "Bearer invalid-key"}
"/v1/wallet/", headers={"Authorization": "Bearer invalid-key"}
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_refund_balance_with_address(
async_client: AsyncClient,
test_api_key: ApiKey,
test_session: AsyncSession
async_client: AsyncClient, test_api_key: ApiKey, test_session: AsyncSession
):
"""Test refunding balance when refund address is set."""
# Need to patch the refund_balance at the module level to intercept the call
with patch("router.account.refund_balance", new_callable=AsyncMock) as mock_refund:
mock_refund.return_value = 1000000
response = await async_client.post(
"/v1/wallet/refund",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"}
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"},
)
assert response.status_code == 200
data = response.json()
assert data["recipient"] == "test@lightning.address"
assert data["msats"] == 1000000
# Verify balance was zeroed
await test_session.refresh(test_api_key)
assert test_api_key.balance == 0
# Verify refund_balance was called
mock_refund.assert_called_once()
@pytest.mark.asyncio
async def test_refund_balance_without_address(
async_client: AsyncClient,
test_session: AsyncSession
async_client: AsyncClient, test_session: AsyncSession
):
"""Test refunding balance when no refund address is set."""
# Create key without refund address - with unique ID
unique_id = str(uuid.uuid4())[:8]
api_key = f"test-key-no-refund-{unique_id}"
key = ApiKey(
hashed_key=api_key,
balance=500000,
refund_address=None,
total_spent=0,
total_requests=0
total_requests=0,
)
test_session.add(key)
await test_session.commit()
# Mock at the router.account module level
with patch("router.account.create_token", new_callable=AsyncMock) as mock_create_token:
mock_create_token.return_value = "cashuBqQSEQ..."
# Mock the WALLET instance at the router.account module level
with patch("router.account.WALLET") as mock_wallet:
mock_wallet.send = AsyncMock(return_value="cashuBqQSEQ...")
response = await async_client.post(
"/v1/wallet/refund",
headers={"Authorization": f"Bearer sk-{api_key}"}
"/v1/wallet/refund", headers={"Authorization": f"Bearer sk-{api_key}"}
)
assert response.status_code == 200
data = response.json()
assert data["recipient"] is None
assert data["msats"] == 500000
assert data["token"] == "cashuBqQSEQ..."
# Verify create_token was called with the correct amount
mock_create_token.assert_called_once_with(500000)
# Verify wallet.send was called with the correct amount (msats converted to sats)
mock_wallet.send.assert_called_once_with(500)
@pytest.mark.asyncio
async def test_topup_balance_endpoint(
async_client: AsyncClient,
test_api_key: ApiKey,
test_session: AsyncSession
async_client: AsyncClient, test_api_key: ApiKey, test_session: AsyncSession
):
"""Test topping up balance with a cashu token."""
# Mock at the router.account module level to intercept the import
with patch("router.account.credit_balance", new_callable=AsyncMock) as mock_credit:
mock_credit.return_value = {"msats": 500000}
response = await async_client.post(
"/v1/wallet/topup?cashu_token=cashuBqQSEQ...",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"}
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"},
)
assert response.status_code == 200
data = response.json()
assert data == {"msats": 500000}
# Verify credit_balance was called
mock_credit.assert_called_once()
@pytest.mark.asyncio
async def test_topup_balance_requires_cashu_token(
async_client: AsyncClient,
test_api_key: ApiKey
async_client: AsyncClient, test_api_key: ApiKey
):
"""Test that topup endpoint requires a cashu token."""
response = await async_client.post(
"/v1/wallet/topup",
headers={"Authorization": f"Bearer sk-{test_api_key.hashed_key}"},
json={}
json={},
)
assert response.status_code == 422 # Missing required field
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_account_with_cashu_token(
async_client: AsyncClient,
test_session: AsyncSession
async_client: AsyncClient, test_session: AsyncSession
):
"""Test authentication with a cashu token creates a new account."""
cashu_token = "cashuBqQSEQ123456"
with patch("router.cashu.credit_balance", new_callable=AsyncMock) as mock_credit:
# Mock successful token redemption
mock_credit.return_value = 5000000 # 5000 sats
# Mock token deserialization
with patch("router.cashu.deserialize_token_from_string") as mock_deserialize:
mock_token = MagicMock()
mock_token.mint = "https://test.mint.com"
mock_deserialize.return_value = mock_token
# Mock wallet receive
with patch("router.cashu._handle_token_receive", new_callable=AsyncMock) as mock_receive:
mock_receive.return_value = 5000000
response = await async_client.get(
"/v1/wallet/",
headers={"Authorization": f"Bearer {cashu_token}"}
)
assert response.status_code == 200
data = response.json()
# Check that a new key was created with the hashed token
assert data["api_key"].startswith("sk-")
assert data["balance"] >= 0 # Balance should be set after credit_balance
async def mock_credit_balance(
token: str, key: ApiKey, session: AsyncSession
) -> int:
"""Mock credit_balance function that simulates adding balance and committing."""
amount = 5000000 # 5000 sats in msats
key.balance += amount
session.add(key)
await session.commit()
return amount
with patch(
"router.cashu.credit_balance",
new_callable=AsyncMock,
side_effect=mock_credit_balance,
):
response = await async_client.get(
"/v1/wallet/", headers={"Authorization": f"Bearer {cashu_token}"}
)
assert response.status_code == 200
data = response.json()
# Check that a new key was created with the hashed token
assert data["api_key"].startswith("sk-")
assert data["balance"] >= 0 # Balance should be set after credit_balance
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest
import asyncio
from unittest.mock import patch, AsyncMock, MagicMock
from unittest.mock import patch, AsyncMock
from router.models import Model, Architecture, Pricing, TopProvider, update_sats_pricing, MODELS
+92 -104
View File
@@ -4,7 +4,7 @@ import json
import os
import uuid
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient, Response as HttpxResponse
from httpx import AsyncClient
from router.db import ApiKey, AsyncSession
@@ -17,7 +17,7 @@ async def api_key_with_balance(test_session: AsyncSession) -> ApiKey:
balance=10000000, # 10,000 sats in msats
refund_address=None,
total_spent=0,
total_requests=0
total_requests=0,
)
test_session.add(key)
await test_session.commit()
@@ -29,15 +29,17 @@ async def api_key_with_balance(test_session: AsyncSession) -> ApiKey:
async def test_proxy_requires_authentication(async_client: AsyncClient):
"""Test that proxy endpoints require authentication."""
response = await async_client.post("/v1/chat/completions")
assert response.status_code == 401
assert "API key or Cashu token required" in response.json()["detail"]["error"]["message"]
assert (
"API key or Cashu token required"
in response.json()["detail"]["error"]["message"]
)
@pytest.mark.asyncio
async def test_proxy_with_insufficient_balance(
async_client: AsyncClient,
test_session: AsyncSession
async_client: AsyncClient, test_session: AsyncSession
):
"""Test proxy request with insufficient balance."""
# Create key with minimal balance
@@ -47,38 +49,37 @@ async def test_proxy_with_insufficient_balance(
balance=100, # Only 0.1 sats
refund_address=None,
total_spent=0,
total_requests=0
total_requests=0,
)
test_session.add(key)
await test_session.commit()
# Mock the models.json check
with patch("os.path.exists", return_value=False):
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]}
json={"model": "gpt-4", "messages": [{"role": "user", "content": "Hello"}]},
)
assert response.status_code == 402
assert "Insufficient balance" in response.json()["detail"]["error"]["message"]
@pytest.mark.asyncio
async def test_proxy_invalid_json_body(
async_client: AsyncClient,
api_key_with_balance: ApiKey
async_client: AsyncClient, api_key_with_balance: ApiKey
):
"""Test proxy request with invalid JSON body."""
response = await async_client.post(
"/v1/chat/completions",
headers={
"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}",
"Content-Type": "application/json"
"Content-Type": "application/json",
},
content=b'{"invalid": json",}' # Invalid JSON
content=b'{"invalid": json",}', # Invalid JSON
)
assert response.status_code == 400
error_data = response.json()
assert error_data["error"]["type"] == "invalid_request_error"
@@ -87,9 +88,7 @@ async def test_proxy_invalid_json_body(
@pytest.mark.asyncio
async def test_proxy_successful_request_mock(
async_client: AsyncClient,
api_key_with_balance: ApiKey,
test_session: AsyncSession
async_client: AsyncClient, api_key_with_balance: ApiKey, test_session: AsyncSession
):
"""Test successful proxy request with mocked upstream."""
mock_response_data = {
@@ -97,60 +96,61 @@ async def test_proxy_successful_request_mock(
"object": "chat.completion",
"created": 1677652288,
"model": "gpt-4",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "Hello! How can I help you?"},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 9,
"completion_tokens": 10,
"total_tokens": 19
}
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! How can I help you?",
},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 9, "completion_tokens": 10, "total_tokens": 19},
}
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
# Create a mock response
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.aread = AsyncMock(return_value=json.dumps(mock_response_data).encode())
mock_response.aread = AsyncMock(
return_value=json.dumps(mock_response_data).encode()
)
mock_response.aiter_bytes = AsyncMock()
mock_response.aclose = AsyncMock()
mock_client.send = AsyncMock(return_value=mock_response)
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
# Also mock the models.json check and pay_out
with patch("os.path.exists", return_value=False):
with patch("router.cashu.pay_out_with_new_session") as mock_payout:
with patch("router.cashu.pay_out") as mock_payout:
mock_payout.return_value = None
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"},
headers={
"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"
},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 200
response_json = response.json()
# Verify the response includes the original data plus cost
assert response_json["id"] == "chatcmpl-123"
assert "cost" in response_json
assert response_json["cost"]["total_msats"] >= 0
# Verify balance was deducted
await test_session.refresh(api_key_with_balance)
assert api_key_with_balance.balance < 10000000
@@ -159,8 +159,7 @@ async def test_proxy_successful_request_mock(
@pytest.mark.asyncio
async def test_proxy_streaming_response(
async_client: AsyncClient,
api_key_with_balance: ApiKey
async_client: AsyncClient, api_key_with_balance: ApiKey
):
"""Test proxy request with streaming response."""
# Mock SSE stream chunks
@@ -168,78 +167,73 @@ async def test_proxy_streaming_response(
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4","choices":[{"delta":{"content":"Hello"},"index":0}]}\n\n',
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4","choices":[{"delta":{"content":" there!"},"index":0}]}\n\n',
b'data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gpt-4","choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":3,"total_tokens":12}}\n\n',
b'data: [DONE]\n\n'
b"data: [DONE]\n\n",
]
async def mock_aiter_bytes():
for chunk in stream_chunks:
yield chunk
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "text/event-stream"}
mock_response.aiter_bytes = lambda: mock_aiter_bytes()
mock_response.aclose = AsyncMock()
mock_client.send = AsyncMock(return_value=mock_response)
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
with patch("os.path.exists", return_value=False):
with patch("router.cashu.pay_out_with_new_session") as mock_payout:
with patch("router.cashu.pay_out") as mock_payout:
mock_payout.return_value = None
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"},
headers={
"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"
},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}],
"stream": True
}
"stream": True,
},
)
assert response.status_code == 200
assert response.headers["content-type"] == "text/event-stream"
@pytest.mark.asyncio
async def test_proxy_handles_upstream_errors(
async_client: AsyncClient,
api_key_with_balance: ApiKey
async_client: AsyncClient, api_key_with_balance: ApiKey
):
"""Test proxy handles upstream connection errors gracefully."""
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
# Simulate connection error
mock_client.send.side_effect = Exception("Connection refused")
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
with patch("os.path.exists", return_value=False):
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"},
headers={
"Authorization": f"Bearer sk-{api_key_with_balance.hashed_key}"
},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}
"messages": [{"role": "user", "content": "Hello"}],
},
)
assert response.status_code == 500
error_data = response.json()
assert error_data["error"]["type"] == "internal_error"
@@ -248,8 +242,7 @@ async def test_proxy_handles_upstream_errors(
@pytest.mark.asyncio
async def test_proxy_with_model_based_pricing(
async_client: AsyncClient,
test_session: AsyncSession
async_client: AsyncClient, test_session: AsyncSession
):
"""Test proxy with model-based pricing enabled."""
# Create API key with sufficient balance
@@ -259,17 +252,16 @@ async def test_proxy_with_model_based_pricing(
balance=10000000, # 10,000 sats
refund_address=None,
total_spent=0,
total_requests=0
total_requests=0,
)
test_session.add(key)
await test_session.commit()
# Patch the MODEL_BASED_PRICING constant directly
with patch("router.auth.MODEL_BASED_PRICING", True):
with patch.dict(os.environ, {"MODEL_BASED_PRICING": "true"}):
with patch("os.path.exists", return_value=True):
# Mock a model with pricing
from router.models import MODELS, Model, Pricing, Architecture, TopProvider
test_model = Model(
id="gpt-4",
name="GPT-4",
@@ -281,7 +273,7 @@ async def test_proxy_with_model_based_pricing(
input_modalities=["text"],
output_modalities=["text"],
tokenizer="cl100k_base",
instruct_type="none"
instruct_type="none",
),
pricing=Pricing(
prompt=0.03,
@@ -289,7 +281,7 @@ async def test_proxy_with_model_based_pricing(
request=0.001,
image=0.0,
web_search=0.0,
internal_reasoning=0.0
internal_reasoning=0.0,
),
sats_pricing=Pricing(
prompt=300, # 300 sats per 1k tokens
@@ -298,54 +290,50 @@ async def test_proxy_with_model_based_pricing(
image=0.0,
web_search=0.0,
internal_reasoning=0.0,
max_cost=5000 # 5000 sats max
max_cost=5000, # 5000 sats max
),
top_provider=TopProvider(
context_length=8192,
max_completion_tokens=4096,
is_moderated=False
)
context_length=8192, max_completion_tokens=4096, is_moderated=False
),
)
# Temporarily replace models
original_models = MODELS[:]
MODELS.clear()
MODELS.append(test_model)
# Mock the upstream HTTP client
with patch("httpx.AsyncClient") as mock_client_class:
mock_client = AsyncMock()
mock_client_class.return_value = mock_client
# Add async context manager methods
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock()
# Create a mock response
mock_response = AsyncMock()
mock_response.status_code = 200
mock_response.headers = {"content-type": "application/json"}
mock_response.aread = AsyncMock(return_value=b'{"id": "test", "model": "gpt-4"}')
mock_response.aread = AsyncMock(
return_value=b'{"id": "test", "model": "gpt-4"}'
)
mock_response.aiter_bytes = AsyncMock()
mock_response.aclose = AsyncMock()
mock_client.send = AsyncMock(return_value=mock_response)
mock_client.build_request = AsyncMock()
mock_client.aclose = AsyncMock()
try:
response = await async_client.post(
"/v1/chat/completions",
headers={"Authorization": f"Bearer sk-{key.hashed_key}"},
json={
"model": "gpt-4",
"messages": [{"role": "user", "content": "Hello"}]
}
"messages": [{"role": "user", "content": "Hello"}],
},
)
# Should succeed because balance (10,000 sats) > max_cost (5000 sats)
assert response.status_code == 200
finally:
MODELS.clear()
MODELS.extend(original_models)
MODELS.extend(original_models)
Generated
+153 -711
View File
File diff suppressed because it is too large Load Diff