mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-12 12:13:21 +00:00
Merge pull request #14 from Routstr/sixty-nuts-migration
Sixty nuts migration
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user