mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-07-31 07:46:15 +00:00
Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1fa7d094f | ||
|
|
eed5bc5b04 | ||
|
|
f4b014cb05 | ||
|
|
4418d87664 | ||
|
|
634a473f50 | ||
|
|
ea655b748b | ||
|
|
2d247ddc8b | ||
|
|
c064452aea | ||
|
|
1c6a603042 | ||
|
|
84b0007b05 | ||
|
|
525476ccfa | ||
|
|
b54812cb04 | ||
|
|
ee508cbb3a | ||
|
|
0c61fdee07 | ||
|
|
b9418db31f | ||
|
|
71e7c2171b | ||
|
|
a3e8d5fd38 | ||
|
|
41fd2e2dfc | ||
|
|
2c404c66d6 | ||
|
|
0fa3e77f9a | ||
|
|
5b8e56f590 | ||
|
|
a6d0bd1a19 | ||
|
|
9594e9fb52 | ||
|
|
b01c7b2e56 | ||
|
|
dd4ed7541f | ||
|
|
cc42534a97 | ||
|
|
d203370f01 | ||
|
|
e39742c429 | ||
|
|
301dd81215 | ||
|
|
5416cefd87 | ||
|
|
8edc3512c1 | ||
|
|
43e97326e0 | ||
|
|
06770a0702 | ||
|
|
590fb4bc2c | ||
|
|
5db9abc3ce | ||
|
|
547365894d | ||
|
|
c0176a5274 | ||
|
|
329d22363f | ||
|
|
9438bc957f | ||
|
|
5d2219880d | ||
|
|
195da0c9da | ||
|
|
8df0c17bc3 | ||
|
|
7bc9ee0653 | ||
|
|
355f8601c1 |
-37
@@ -1,37 +0,0 @@
|
||||
import os
|
||||
|
||||
import openai
|
||||
|
||||
client = openai.OpenAI(
|
||||
api_key=os.environ["CASHU_TOKEN"],
|
||||
base_url=os.environ.get("ROUTSTR_API_URL", "https://api.routstr.com/v1"),
|
||||
# base_url="http://roustrjfsdgfiueghsklchg.onion/v1",
|
||||
# client=httpx.AsyncClient(
|
||||
# proxies={"http": "socks5://localhost:9050"},
|
||||
# ), # to use onion proxy (tor)
|
||||
)
|
||||
history: list = []
|
||||
|
||||
|
||||
def chat() -> None:
|
||||
while True:
|
||||
user_msg = {"role": "user", "content": input("\nYou: ")}
|
||||
history.append(user_msg)
|
||||
ai_msg = {"role": "assistant", "content": ""}
|
||||
|
||||
for chunk in client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "openai/gpt-4o-mini"),
|
||||
messages=history,
|
||||
stream=True,
|
||||
):
|
||||
if len(chunk.choices) > 0:
|
||||
content = chunk.choices[0].delta.content
|
||||
if content is not None:
|
||||
ai_msg["content"] += content
|
||||
print(content, end="", flush=True)
|
||||
print()
|
||||
history.append(ai_msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
chat()
|
||||
@@ -0,0 +1,11 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token,
|
||||
# cashu token is hashed on the server and acts as an Temporary API key
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/info", headers=headers)
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Send a Cashu token to the /create endpoint to get a persistent API key
|
||||
token = os.environ.get("TOKEN")
|
||||
if not token:
|
||||
print("Please set TOKEN environment variable with a Cashu token")
|
||||
exit(1)
|
||||
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.get(f"{base_url}/balance/create", params={"initial_balance_token": token})
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,12 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
resp = httpx.post(f"{base_url}/balance/refund", headers=headers)
|
||||
|
||||
print("Refund successful!")
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
# Use your Cashu token or API key as the Bearer token
|
||||
headers = {"Authorization": f"Bearer {os.environ.get('TOKEN')}"}
|
||||
base_url = os.environ.get("API_URL", "https://api.routstr.com/v1")
|
||||
|
||||
# The Cashu token to top up with
|
||||
cashu_token = input("Enter Cashu token to top up: ")
|
||||
|
||||
resp = httpx.post(
|
||||
f"{base_url}/balance/topup", headers=headers, json={"cashu_token": cashu_token}
|
||||
)
|
||||
|
||||
print(resp.json())
|
||||
@@ -0,0 +1,15 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5-nano"),
|
||||
messages=[{"role": "user", "content": "Hello!"}],
|
||||
)
|
||||
|
||||
print(response.choices[0].message.content)
|
||||
@@ -0,0 +1,19 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN", ""),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
for model in client.models.list():
|
||||
print(model.id)
|
||||
|
||||
# OR
|
||||
|
||||
models = httpx.get(
|
||||
f"{client.base_url}/v1/models",
|
||||
headers={"Authorization": f"Bearer {client.api_key}"},
|
||||
).json()
|
||||
@@ -0,0 +1,31 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
conversation = [] # type: ignore
|
||||
|
||||
# First turn
|
||||
response1 = client.responses.create( # type: ignore
|
||||
model="o4-mini",
|
||||
input="Hi, my name is Alice.",
|
||||
conversation=conversation,
|
||||
)
|
||||
print("Response 1:", response1.output)
|
||||
|
||||
# Note: The 'conversation' parameter might need to be constructed differently
|
||||
# depending on exact SDK/API spec. Typically, you pass back the previous turn's data.
|
||||
# Assuming the SDK manages or returns a conversation object/ID:
|
||||
# conversation.append(response1)
|
||||
|
||||
# Second turn - demonstrating intent, actual implementation depends on strict API spec
|
||||
# response2 = client.responses.create(
|
||||
# model="openai/gpt-4o-mini",
|
||||
# input="What is my name?",
|
||||
# conversation=conversation,
|
||||
# )
|
||||
# print("Response 2:", response2.output)
|
||||
@@ -0,0 +1,17 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
# The OpenAI SDK handles the 'responses' endpoint if it's updated to the latest version
|
||||
# and the base_url points to a compatible proxy like Routstr.
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="Tell me a three sentence bedtime story about a unicorn.",
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
stream = client.responses.create(
|
||||
model="claude-4.5-sonnet",
|
||||
input="Write a short poem about rust.",
|
||||
stream=True,
|
||||
)
|
||||
|
||||
for event in stream:
|
||||
# Note: Depending on the SDK version and response structure,
|
||||
# you might access event.output_delta or similar fields
|
||||
print(event, end="", flush=True)
|
||||
print()
|
||||
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="gpt-5-mini",
|
||||
input="What is the latest news about AI?",
|
||||
tools=[{"type": "web_search"}], # type: ignore
|
||||
)
|
||||
|
||||
print(response.output)
|
||||
@@ -0,0 +1,28 @@
|
||||
import os
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("API_URL", "https://api.routstr.com/v1"),
|
||||
)
|
||||
|
||||
messages = []
|
||||
while True:
|
||||
messages.append({"role": "user", "content": input("\nYou: ")})
|
||||
|
||||
stream = client.chat.completions.create(
|
||||
model=os.environ.get("MODEL", "gpt-5.1-mini"),
|
||||
messages=messages, # type: ignore
|
||||
stream=True,
|
||||
)
|
||||
|
||||
print("AI: ", end="")
|
||||
response_content = ""
|
||||
for chunk in stream:
|
||||
if content := chunk.choices[0].delta.content: # type: ignore
|
||||
print(content, end="", flush=True)
|
||||
response_content += content
|
||||
print()
|
||||
|
||||
messages.append({"role": "assistant", "content": response_content})
|
||||
@@ -0,0 +1,20 @@
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from openai import OpenAI
|
||||
|
||||
# Requires `pip install "httpx[socks]"` and a running Tor proxy on port 9050
|
||||
client = OpenAI(
|
||||
api_key=os.environ.get("TOKEN"),
|
||||
base_url=os.environ.get("ONION_URL", "http://roustrjfsdgfiueghsklchg.onion/v1"),
|
||||
http_client=httpx.Client(proxies="socks5://localhost:9050"),
|
||||
)
|
||||
|
||||
print(
|
||||
client.chat.completions.create(
|
||||
model="openai/gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "Hello from Tor!"}],
|
||||
)
|
||||
.choices[0]
|
||||
.message.content
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""alias-ids
|
||||
|
||||
Revision ID: b9667ffc5701
|
||||
Revises: lightning_invoices
|
||||
Create Date: 2025-12-25 19:30:44.673350
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b9667ffc5701"
|
||||
down_revision = "lightning_invoices"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic ###
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("canonical_slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"models",
|
||||
sa.Column("alias_ids", sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("models", "alias_ids")
|
||||
op.drop_column("models", "canonical_slug")
|
||||
# ### end Alembic commands ###
|
||||
@@ -73,6 +73,7 @@ packages = ["routstr"]
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = ["E501"]
|
||||
exclude = ["examples"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.11"
|
||||
|
||||
@@ -206,19 +206,20 @@ def create_model_mappings(
|
||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||
) -> None:
|
||||
"""Set alias to model/provider if not set or if new model is preferred."""
|
||||
existing_model = model_instances.get(alias)
|
||||
alias_lower = alias.lower()
|
||||
existing_model = model_instances.get(alias_lower)
|
||||
if not existing_model:
|
||||
# No existing mapping, set it
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
model_instances[alias_lower] = model
|
||||
provider_map[alias_lower] = provider
|
||||
else:
|
||||
# Check if candidate should replace existing
|
||||
existing_provider = provider_map[alias]
|
||||
existing_provider = provider_map[alias_lower]
|
||||
if should_prefer_model(
|
||||
model, provider, existing_model, existing_provider, alias
|
||||
):
|
||||
model_instances[alias] = model
|
||||
provider_map[alias] = provider
|
||||
model_instances[alias_lower] = model
|
||||
provider_map[alias_lower] = provider
|
||||
|
||||
def process_provider_models(
|
||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||
|
||||
+78
-110
@@ -1493,20 +1493,8 @@ class ModelCreate(BaseModel):
|
||||
per_request_limits: dict[str, object] | None = None
|
||||
top_provider: dict[str, object] | None = None
|
||||
upstream_provider_id: int | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class ModelUpdate(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
created: int
|
||||
context_length: int
|
||||
architecture: dict[str, object]
|
||||
pricing: dict[str, object]
|
||||
per_request_limits: dict[str, object] | None = None
|
||||
top_provider: dict[str, object] | None = None
|
||||
upstream_provider_id: int | None = None
|
||||
canonical_slug: str | None = None
|
||||
alias_ids: list[str] | None = None
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
@@ -2416,44 +2404,80 @@ async def admin_upstream_providers(request: Request) -> str:
|
||||
"/api/upstream-providers/{provider_id}/models",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def create_provider_model(
|
||||
async def upsert_provider_model(
|
||||
provider_id: int, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
print(payload)
|
||||
logger.info(
|
||||
f"UPSERT_PROVIDER_MODEL called: provider_id={provider_id}, model_id={payload.id}"
|
||||
)
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
exists = await session.get(ModelRow, (payload.id, provider_id))
|
||||
if exists:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Model with this ID already exists for this provider",
|
||||
)
|
||||
# Try to get existing model
|
||||
existing_row = await session.get(ModelRow, (payload.id, provider_id))
|
||||
|
||||
row = ModelRow(
|
||||
id=payload.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created=int(payload.created),
|
||||
context_length=int(payload.context_length),
|
||||
architecture=json.dumps(payload.architecture),
|
||||
pricing=json.dumps(payload.pricing),
|
||||
sats_pricing=None,
|
||||
per_request_limits=(
|
||||
if existing_row:
|
||||
# Update existing model
|
||||
logger.info(f"Updating existing model: {payload.id}")
|
||||
existing_row.name = payload.name
|
||||
existing_row.description = payload.description
|
||||
existing_row.created = int(payload.created)
|
||||
existing_row.context_length = int(payload.context_length)
|
||||
existing_row.architecture = json.dumps(payload.architecture)
|
||||
existing_row.pricing = json.dumps(payload.pricing)
|
||||
existing_row.sats_pricing = None
|
||||
existing_row.per_request_limits = (
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
),
|
||||
top_provider=(
|
||||
)
|
||||
existing_row.top_provider = (
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
)
|
||||
existing_row.canonical_slug = payload.canonical_slug
|
||||
existing_row.alias_ids = (
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
)
|
||||
existing_row.enabled = payload.enabled
|
||||
|
||||
session.add(existing_row)
|
||||
await session.commit()
|
||||
await session.refresh(existing_row)
|
||||
row = existing_row
|
||||
|
||||
else:
|
||||
# Create new model
|
||||
logger.info(f"Creating new model: {payload.id}")
|
||||
row = ModelRow(
|
||||
id=payload.id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created=int(payload.created),
|
||||
context_length=int(payload.context_length),
|
||||
architecture=json.dumps(payload.architecture),
|
||||
pricing=json.dumps(payload.pricing),
|
||||
sats_pricing=None,
|
||||
per_request_limits=(
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
),
|
||||
top_provider=(
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
),
|
||||
canonical_slug=payload.canonical_slug,
|
||||
alias_ids=(
|
||||
json.dumps(payload.alias_ids) if payload.alias_ids else None
|
||||
),
|
||||
upstream_provider_id=provider_id,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
await refresh_model_maps()
|
||||
return _row_to_model(
|
||||
@@ -2461,6 +2485,20 @@ async def create_provider_model(
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model_legacy(
|
||||
provider_id: int, model_id: str, payload: ModelCreate
|
||||
) -> dict[str, object]:
|
||||
"""Legacy PATCH endpoint - redirects to upsert POST endpoint for backward compatibility."""
|
||||
logger.info(
|
||||
f"LEGACY_PATCH_UPDATE called: provider_id={provider_id}, model_id={model_id}"
|
||||
)
|
||||
return await upsert_provider_model(provider_id, payload)
|
||||
|
||||
|
||||
@admin_router.get(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
@@ -2481,76 +2519,6 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.patch(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model(
|
||||
provider_id: int, model_id: str, payload: ModelUpdate
|
||||
) -> dict[str, object]:
|
||||
if payload.id != model_id:
|
||||
raise HTTPException(status_code=400, detail="Path id does not match payload id")
|
||||
|
||||
async with create_session() as session:
|
||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="Provider not found")
|
||||
|
||||
row = await session.get(ModelRow, (model_id, provider_id))
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Model not found for this provider"
|
||||
)
|
||||
|
||||
row.name = payload.name
|
||||
row.description = payload.description
|
||||
row.created = int(payload.created)
|
||||
row.context_length = int(payload.context_length)
|
||||
row.architecture = json.dumps(payload.architecture)
|
||||
row.pricing = json.dumps(payload.pricing)
|
||||
row.sats_pricing = None
|
||||
row.per_request_limits = (
|
||||
json.dumps(payload.per_request_limits)
|
||||
if payload.per_request_limits is not None
|
||||
else None
|
||||
)
|
||||
row.top_provider = (
|
||||
json.dumps(payload.top_provider) if payload.top_provider else None
|
||||
)
|
||||
was_disabled = not row.enabled
|
||||
row.enabled = payload.enabled
|
||||
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
|
||||
if was_disabled and payload.enabled:
|
||||
from ..payment.models import _cleanup_enabled_models_once
|
||||
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to run model cleanup after enabling: {e}",
|
||||
extra={"model_id": model_id, "error": str(e)},
|
||||
)
|
||||
|
||||
await refresh_model_maps()
|
||||
return _row_to_model(
|
||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
||||
).dict() # type: ignore
|
||||
|
||||
|
||||
@admin_router.put(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
)
|
||||
async def update_provider_model_put(
|
||||
provider_id: int, model_id: str, payload: ModelUpdate
|
||||
) -> dict[str, object]:
|
||||
return await update_provider_model(provider_id, model_id, payload)
|
||||
|
||||
|
||||
@admin_router.delete(
|
||||
"/api/upstream-providers/{provider_id}/models/{model_id:path}",
|
||||
dependencies=[Depends(require_admin_api)],
|
||||
|
||||
@@ -68,6 +68,10 @@ class ModelRow(SQLModel, table=True): # type: ignore
|
||||
sats_pricing: str | None = Field(default=None)
|
||||
per_request_limits: str | None = Field(default=None)
|
||||
top_provider: str | None = Field(default=None)
|
||||
canonical_slug: str | None = Field(default=None, description="Canonical model slug")
|
||||
alias_ids: str | None = Field(
|
||||
default=None, description="JSON array of model alias IDs"
|
||||
)
|
||||
enabled: bool = Field(default=True, description="Whether this model is enabled")
|
||||
upstream_provider: "UpstreamProviderRow" = Relationship(back_populates="models")
|
||||
|
||||
|
||||
+5
-12
@@ -14,7 +14,6 @@ from ..balance import balance_router, deprecated_wallet_router
|
||||
from ..discovery import providers_cache_refresher, providers_router
|
||||
from ..nip91 import announce_provider
|
||||
from ..payment.models import (
|
||||
cleanup_enabled_models_periodically,
|
||||
models_router,
|
||||
update_sats_pricing,
|
||||
)
|
||||
@@ -49,7 +48,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
nip91_task = None
|
||||
providers_task = None
|
||||
models_refresh_task = None
|
||||
models_cleanup_task = None
|
||||
model_maps_refresh_task = None
|
||||
|
||||
try:
|
||||
@@ -80,13 +78,17 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
_update_prices_task = asyncio.create_task(_update_prices())
|
||||
_initialize_upstreams_task = asyncio.create_task(initialize_upstreams())
|
||||
|
||||
# ensure both setup tasks complete
|
||||
await asyncio.gather(
|
||||
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
|
||||
)
|
||||
|
||||
btc_price_task = asyncio.create_task(update_prices_periodically())
|
||||
pricing_task = asyncio.create_task(update_sats_pricing())
|
||||
if global_settings.models_refresh_interval_seconds > 0:
|
||||
models_refresh_task = asyncio.create_task(
|
||||
refresh_upstreams_models_periodically(get_upstreams())
|
||||
)
|
||||
models_cleanup_task = asyncio.create_task(cleanup_enabled_models_periodically())
|
||||
model_maps_refresh_task = asyncio.create_task(refresh_model_maps_periodically())
|
||||
payout_task = asyncio.create_task(periodic_payout())
|
||||
if global_settings.nsec:
|
||||
@@ -94,11 +96,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
if global_settings.providers_refresh_interval_seconds > 0:
|
||||
providers_task = asyncio.create_task(providers_cache_refresher())
|
||||
|
||||
# ensure both setup tasks complete
|
||||
await asyncio.gather(
|
||||
_update_prices_task, _initialize_upstreams_task, return_exceptions=True
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
except asyncio.CancelledError:
|
||||
@@ -125,8 +122,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
providers_task.cancel()
|
||||
if models_refresh_task is not None:
|
||||
models_refresh_task.cancel()
|
||||
if models_cleanup_task is not None:
|
||||
models_cleanup_task.cancel()
|
||||
if model_maps_refresh_task is not None:
|
||||
model_maps_refresh_task.cancel()
|
||||
|
||||
@@ -144,8 +139,6 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
tasks_to_wait.append(providers_task)
|
||||
if models_refresh_task is not None:
|
||||
tasks_to_wait.append(models_refresh_task)
|
||||
if models_cleanup_task is not None:
|
||||
tasks_to_wait.append(models_cleanup_task)
|
||||
if model_maps_refresh_task is not None:
|
||||
tasks_to_wait.append(model_maps_refresh_task)
|
||||
|
||||
|
||||
@@ -55,7 +55,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
||||
"headers": {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() not in ["authorization", "x-cashu", "cookie"]
|
||||
if k.lower()
|
||||
not in [
|
||||
"authorization",
|
||||
"x-cashu",
|
||||
"cookie",
|
||||
"cf-connecting-ip",
|
||||
"cf-ipcountry",
|
||||
"x-forwarded-for",
|
||||
"x-real-ip",
|
||||
]
|
||||
},
|
||||
"body_size": len(request_body) if request_body else 0,
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ from pydantic.v1 import BaseModel
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession
|
||||
from ..core.settings import settings
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -64,6 +65,56 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
return cost_data
|
||||
|
||||
usage_data = response_data["usage"]
|
||||
|
||||
usd_cost = 0.0
|
||||
|
||||
# Prioritize cost_details.upstream_inference_cost
|
||||
if "cost_details" in usage_data:
|
||||
usd_cost = float(
|
||||
usage_data["cost_details"].get("upstream_inference_cost", 0) or 0
|
||||
)
|
||||
|
||||
# Fallback to cost field if upstream_inference_cost is 0
|
||||
if usd_cost == 0 and "cost" in usage_data:
|
||||
try:
|
||||
usd_cost = float(usage_data.get("cost", 0) or 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if usd_cost > 0:
|
||||
try:
|
||||
sats_per_usd = 1.0 / sats_usd_price()
|
||||
cost_in_sats = usd_cost * sats_per_usd
|
||||
cost_in_msats = math.ceil(cost_in_sats * 1000)
|
||||
|
||||
logger.info(
|
||||
"Using cost from usage data/details",
|
||||
extra={
|
||||
"usd_cost": usd_cost,
|
||||
"cost_in_sats": cost_in_sats,
|
||||
"cost_in_msats": cost_in_msats,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return CostData(
|
||||
base_msats=-1,
|
||||
input_msats=-1, # Cost field doesn't break down by token type
|
||||
output_msats=-1,
|
||||
total_msats=cost_in_msats,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Error calculating cost from usage data",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"usd_cost": usd_cost,
|
||||
"model": response_data.get("model", "unknown"),
|
||||
},
|
||||
)
|
||||
# Fall through to token-based calculation
|
||||
|
||||
MSATS_PER_1K_INPUT_TOKENS: float = (
|
||||
float(settings.fixed_per_1k_input_tokens) * 1000.0
|
||||
)
|
||||
@@ -129,21 +180,21 @@ async def calculate_cost( # todo: can be sync
|
||||
)
|
||||
return cost_data
|
||||
|
||||
input_tokens = response_data.get("usage", {}).get("prompt_tokens", 0)
|
||||
output_tokens = response_data.get("usage", {}).get("completion_tokens", 0)
|
||||
input_tokens = usage_data.get("prompt_tokens", 0)
|
||||
output_tokens = usage_data.get("completion_tokens", 0)
|
||||
|
||||
# added for response api
|
||||
input_tokens = (
|
||||
input_tokens
|
||||
if input_tokens != 0
|
||||
else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
input_tokens if input_tokens != 0 else usage_data.get("input_tokens", 0)
|
||||
)
|
||||
output_tokens = (
|
||||
output_tokens
|
||||
if output_tokens != 0
|
||||
else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
output_tokens if output_tokens != 0 else usage_data.get("output_tokens", 0)
|
||||
)
|
||||
|
||||
# added for response api
|
||||
input_tokens = input_tokens if input_tokens != 0 else response_data.get("usage", {}).get("input_tokens", 0)
|
||||
output_tokens = output_tokens if output_tokens != 0 else response_data.get("usage", {}).get("output_tokens", 0)
|
||||
|
||||
input_msats = round(input_tokens / 1000 * MSATS_PER_1K_INPUT_TOKENS, 3)
|
||||
|
||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||
|
||||
+39
-199
@@ -3,15 +3,13 @@ import json
|
||||
import random
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic.v1 import BaseModel
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..core.db import ApiKey, ModelRow, create_session, get_session
|
||||
from ..core.db import ModelRow, get_session
|
||||
from ..core.logging import get_logger
|
||||
from ..core.settings import settings
|
||||
from ..wallet import deserialize_token_from_string
|
||||
from .price import sats_usd_price
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -94,12 +92,32 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(f"{base_url}/models", timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
models_response, embeddings_response = await asyncio.gather(
|
||||
client.get(f"{base_url}/models", timeout=30),
|
||||
client.get(f"{base_url}/embeddings/models", timeout=30),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
def process_models_response(
|
||||
response: httpx.Response | BaseException,
|
||||
) -> list[dict]:
|
||||
if not isinstance(response, BaseException):
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return [
|
||||
model
|
||||
for model in data.get("data", [])
|
||||
if ":free" not in model.get("id", "").lower()
|
||||
]
|
||||
return []
|
||||
|
||||
models_data: list[dict] = []
|
||||
for model in data.get("data", []):
|
||||
models_data.extend(process_models_response(models_response))
|
||||
models_data.extend(process_models_response(embeddings_response))
|
||||
|
||||
# Apply source filter and exclusions
|
||||
filtered_models = []
|
||||
for model in models_data:
|
||||
model_id = model.get("id", "")
|
||||
|
||||
if source_filter:
|
||||
@@ -117,9 +135,9 @@ async def async_fetch_openrouter_models(source_filter: str | None = None) -> lis
|
||||
if not _has_valid_pricing(model):
|
||||
continue
|
||||
|
||||
models_data.append(model)
|
||||
filtered_models.append(model)
|
||||
|
||||
return models_data
|
||||
return filtered_models
|
||||
except Exception as e:
|
||||
logger.error(f"Error (async) fetching models from OpenRouter API: {e}")
|
||||
return []
|
||||
@@ -166,6 +184,7 @@ def _row_to_model(
|
||||
enabled=row.enabled,
|
||||
upstream_provider_id=row.upstream_provider_id,
|
||||
canonical_slug=getattr(row, "canonical_slug", None),
|
||||
alias_ids=json.loads(row.alias_ids) if row.alias_ids else None,
|
||||
)
|
||||
|
||||
if apply_provider_fee:
|
||||
@@ -363,7 +382,7 @@ def _update_model_sats_pricing(model: Model, sats_to_usd: float) -> Model:
|
||||
|
||||
async def _update_sats_pricing_once() -> None:
|
||||
"""Update sats pricing once for all provider models (in-memory only)."""
|
||||
from ..proxy import get_upstreams
|
||||
from ..proxy import get_upstreams, refresh_model_maps
|
||||
|
||||
upstreams = get_upstreams()
|
||||
sats_to_usd = sats_usd_price()
|
||||
@@ -380,6 +399,7 @@ async def _update_sats_pricing_once() -> None:
|
||||
|
||||
if updated_count > 0:
|
||||
logger.info("Updated sats pricing", extra={"models_updated": updated_count})
|
||||
await refresh_model_maps()
|
||||
|
||||
|
||||
async def update_sats_pricing() -> None:
|
||||
@@ -390,7 +410,13 @@ async def update_sats_pricing() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
try:
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Initial sats pricing update failed (will retry in loop)",
|
||||
extra={"error": str(e)},
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -414,197 +440,11 @@ async def update_sats_pricing() -> None:
|
||||
logger.error(f"Error updating sats pricing: {e}")
|
||||
|
||||
|
||||
async def cleanup_enabled_models_periodically() -> None:
|
||||
"""Background task to clean up enabled models that match upstream pricing.
|
||||
|
||||
When model is enabled (enabled=True), remove it from DB if it matches upstream pricing.
|
||||
Keep it in DB only if pricing differs from upstream or if it's disabled.
|
||||
"""
|
||||
interval = getattr(
|
||||
settings, "models_cleanup_interval_seconds", 300
|
||||
) # 5 minutes default
|
||||
if not interval or interval <= 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
await _cleanup_enabled_models_once()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error during enabled models cleanup",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
jitter = max(0.0, float(interval) * 0.1)
|
||||
await asyncio.sleep(interval + random.uniform(0, jitter))
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
|
||||
async def _cleanup_enabled_models_once() -> None:
|
||||
"""Clean up enabled models that match upstream pricing."""
|
||||
from ..proxy import get_upstreams
|
||||
|
||||
async with create_session() as session:
|
||||
# Get all enabled models from DB
|
||||
result = await session.exec(
|
||||
select(ModelRow).where(
|
||||
ModelRow.enabled, # Only enabled models
|
||||
)
|
||||
)
|
||||
db_models = result.all()
|
||||
|
||||
if not db_models:
|
||||
return
|
||||
|
||||
upstreams = get_upstreams()
|
||||
models_to_remove = []
|
||||
|
||||
for db_model in db_models:
|
||||
# Find corresponding upstream model
|
||||
upstream_model = None
|
||||
for upstream in upstreams:
|
||||
upstream_model = upstream.get_cached_model_by_id(db_model.id)
|
||||
if upstream_model:
|
||||
break
|
||||
|
||||
if not upstream_model:
|
||||
continue
|
||||
|
||||
# Compare pricing to see if they match
|
||||
db_pricing = json.loads(db_model.pricing)
|
||||
upstream_pricing = upstream_model.pricing.dict()
|
||||
|
||||
# Check if pricing matches (with small tolerance for float comparison)
|
||||
pricing_matches = _pricing_matches(db_pricing, upstream_pricing)
|
||||
|
||||
if pricing_matches:
|
||||
models_to_remove.append(db_model)
|
||||
logger.info(
|
||||
f"Removing enabled model {db_model.id} - matches upstream pricing",
|
||||
extra={"model_id": db_model.id},
|
||||
)
|
||||
|
||||
# Remove models that match upstream pricing
|
||||
for model in models_to_remove:
|
||||
await session.delete(model)
|
||||
|
||||
if models_to_remove:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Cleaned up {len(models_to_remove)} enabled models that match upstream pricing"
|
||||
)
|
||||
|
||||
|
||||
def _pricing_matches(
|
||||
db_pricing: dict, upstream_pricing: dict, tolerance: float = 0.0
|
||||
) -> bool:
|
||||
"""Check if pricing dictionaries match within tolerance."""
|
||||
keys_to_compare = [
|
||||
"prompt",
|
||||
"completion",
|
||||
"request",
|
||||
"image",
|
||||
"web_search",
|
||||
"internal_reasoning",
|
||||
]
|
||||
|
||||
for key in keys_to_compare:
|
||||
db_val = int(float(db_pricing.get(key, 0.0)) * 1000000)
|
||||
upstream_val = int(float(upstream_pricing.get(key, 0.0)) * 1000000)
|
||||
|
||||
if abs(db_val - upstream_val) > tolerance:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
async def _get_request_balance(request: Request, session: AsyncSession) -> int | None:
|
||||
"""Get the balance from the request headers if authentication is provided."""
|
||||
headers = request.headers
|
||||
token: str | None = None
|
||||
|
||||
if x_cashu := headers.get("x-cashu"):
|
||||
token = x_cashu
|
||||
elif auth := headers.get("authorization"):
|
||||
parts = auth.split(" ")
|
||||
if len(parts) > 1:
|
||||
token = parts[1]
|
||||
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Handle API keys (sk-*)
|
||||
if token.startswith("sk-"):
|
||||
try:
|
||||
# sk- keys use the part after "sk-" as the ID
|
||||
key_id = token[3:]
|
||||
key = await session.get(ApiKey, key_id)
|
||||
if key:
|
||||
return key.balance - key.reserved_balance
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking API key balance: {e}")
|
||||
return None
|
||||
|
||||
# Handle Cashu tokens
|
||||
try:
|
||||
token_obj = deserialize_token_from_string(token)
|
||||
amount_msat = (
|
||||
token_obj.amount if token_obj.unit == "msat" else token_obj.amount * 1000
|
||||
)
|
||||
return amount_msat
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to deserialize cashu token for balance check: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@models_router.get("/v1/models")
|
||||
@models_router.get("/models", include_in_schema=False)
|
||||
async def models(
|
||||
request: Request, session: AsyncSession = Depends(get_session)
|
||||
) -> dict:
|
||||
async def models(session: AsyncSession = Depends(get_session)) -> dict:
|
||||
"""Get all available models from all providers with database overrides applied."""
|
||||
from ..proxy import get_unique_models
|
||||
|
||||
items = get_unique_models()
|
||||
|
||||
# Optional: Filter by user balance if authenticated
|
||||
user_balance = await _get_request_balance(request, session)
|
||||
if user_balance is not None:
|
||||
filtered_items = []
|
||||
|
||||
# Calculate tolerance factor once
|
||||
tol_factor = 1.0
|
||||
if not settings.fixed_pricing and settings.tolerance_percentage > 0:
|
||||
tol_factor = 1.0 - (settings.tolerance_percentage / 100.0)
|
||||
|
||||
fixed_cost = settings.fixed_cost_per_request * 1000
|
||||
min_cost = settings.min_request_msat
|
||||
|
||||
for model in items:
|
||||
model_cost_msats = 0
|
||||
|
||||
if settings.fixed_pricing:
|
||||
model_cost_msats = max(min_cost, fixed_cost)
|
||||
elif model.sats_pricing:
|
||||
# Use model specific pricing
|
||||
max_cost = (
|
||||
model.sats_pricing.max_cost
|
||||
* 1000
|
||||
* tol_factor
|
||||
)
|
||||
model_cost_msats = max(min_cost, int(max_cost))
|
||||
else:
|
||||
# Fallback if no pricing found
|
||||
model_cost_msats = max(min_cost, fixed_cost)
|
||||
|
||||
if model_cost_msats <= user_balance:
|
||||
filtered_items.append(model)
|
||||
|
||||
items = filtered_items
|
||||
|
||||
return {"data": items}
|
||||
|
||||
+24
-28
@@ -65,12 +65,12 @@ def get_upstreams() -> list[BaseUpstreamProvider]:
|
||||
|
||||
def get_model_instance(model_id: str) -> Model | None:
|
||||
"""Get Model instance by ID from global cache."""
|
||||
return _model_instances.get(model_id)
|
||||
return _model_instances.get(model_id.lower())
|
||||
|
||||
|
||||
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
||||
"""Get UpstreamProvider for model ID from global cache."""
|
||||
return _provider_map.get(model_id)
|
||||
return _provider_map.get(model_id.lower())
|
||||
|
||||
|
||||
def get_unique_models() -> list[Model]:
|
||||
@@ -80,31 +80,27 @@ def get_unique_models() -> list[Model]:
|
||||
|
||||
async def refresh_model_maps() -> None:
|
||||
"""Refresh global model and provider maps using the cost-based algorithm."""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
global _model_instances, _provider_map, _unique_models
|
||||
|
||||
# Gather database overrides and disabled models
|
||||
async with create_session() as session:
|
||||
result = await session.exec(select(ModelRow).where(ModelRow.enabled))
|
||||
override_rows = result.all()
|
||||
|
||||
provider_result = await session.exec(select(UpstreamProviderRow))
|
||||
providers_by_id = {p.id: p for p in provider_result.all()}
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {
|
||||
row.id: (
|
||||
row,
|
||||
providers_by_id[row.upstream_provider_id].provider_fee
|
||||
if row.upstream_provider_id in providers_by_id
|
||||
else 1.01,
|
||||
)
|
||||
for row in override_rows
|
||||
if row.upstream_provider_id is not None
|
||||
}
|
||||
|
||||
disabled_result = await session.exec(
|
||||
select(ModelRow.id).where(ModelRow.enabled == False) # noqa: E712
|
||||
# Fetch all providers with their models in a single logical operation
|
||||
query = select(UpstreamProviderRow).options(
|
||||
selectinload(UpstreamProviderRow.models) # type: ignore
|
||||
)
|
||||
disabled_model_ids = {row for row in disabled_result.all()}
|
||||
result = await session.exec(query)
|
||||
provider_rows = result.all()
|
||||
|
||||
overrides_by_id: dict[str, tuple[ModelRow, float]] = {}
|
||||
disabled_model_ids: set[str] = set()
|
||||
|
||||
for provider in provider_rows:
|
||||
for model in provider.models:
|
||||
if model.enabled:
|
||||
overrides_by_id[model.id] = (model, provider.provider_fee)
|
||||
else:
|
||||
disabled_model_ids.add(model.id)
|
||||
|
||||
_model_instances, _provider_map, _unique_models = create_model_mappings(
|
||||
upstreams=_upstreams,
|
||||
@@ -146,7 +142,7 @@ async def proxy(
|
||||
request_body_dict = parse_request_body_json(request_body, path)
|
||||
|
||||
if is_responses_api:
|
||||
model_id = extract_model_from_responses_api_request(request_body_dict)
|
||||
model_id = extract_model_from_responses_request(request_body_dict)
|
||||
else:
|
||||
model_id = request_body_dict.get("model", "unknown")
|
||||
|
||||
@@ -175,7 +171,7 @@ async def proxy(
|
||||
|
||||
if x_cashu := headers.get("x-cashu", None):
|
||||
if is_responses_api:
|
||||
return await upstream.handle_x_cashu_responses_api(
|
||||
return await upstream.handle_x_cashu_responses(
|
||||
request, x_cashu, path, max_cost_for_model, model_obj
|
||||
)
|
||||
else:
|
||||
@@ -205,7 +201,7 @@ async def proxy(
|
||||
headers = upstream.prepare_headers(dict(request.headers))
|
||||
|
||||
if is_responses_api:
|
||||
response = await upstream.forward_responses_api_request(
|
||||
response = await upstream.forward_responses_request(
|
||||
request,
|
||||
path,
|
||||
headers,
|
||||
@@ -328,7 +324,7 @@ async def get_bearer_token_key(
|
||||
raise
|
||||
|
||||
|
||||
def extract_model_from_responses_api_request(request_body_dict: dict[str, Any]) -> str:
|
||||
def extract_model_from_responses_request(request_body_dict: dict[str, Any]) -> str:
|
||||
if model := request_body_dict.get("model"):
|
||||
return model
|
||||
|
||||
@@ -341,7 +337,7 @@ def extract_model_from_responses_api_request(request_body_dict: dict[str, Any])
|
||||
|
||||
logger.warning(
|
||||
"No model found in Responses API request",
|
||||
extra={"body_keys": list(request_body_dict.keys())},
|
||||
extra={"body_keys": list(request_body_dict.keys())}
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
|
||||
+1874
-170
File diff suppressed because it is too large
Load Diff
@@ -1,583 +0,0 @@
|
||||
"""X-Cashu payment request handlers for different API types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from ...core import get_logger
|
||||
from ...wallet import send_token
|
||||
from ..payment.cashu_handler import (
|
||||
CashuTokenError,
|
||||
create_cashu_error_response,
|
||||
validate_and_redeem_token,
|
||||
)
|
||||
from ..processing.http_client import HttpForwarder
|
||||
from ..processing.response_handler import ChatCompletionProcessor, ResponsesApiProcessor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...payment.models import Model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class BaseCashuHandler:
|
||||
"""Base handler for X-Cashu payment requests."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
self.http_forwarder = HttpForwarder(base_url)
|
||||
|
||||
async def send_refund(self, amount: int, unit: str, mint: str | None = None) -> str:
|
||||
"""Create and send a refund token to the user."""
|
||||
logger.debug(
|
||||
"Creating refund token",
|
||||
extra={"amount": amount, "unit": unit, "mint": mint},
|
||||
)
|
||||
|
||||
max_retries = 3
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
refund_token = await send_token(amount, unit=unit, mint_url=mint)
|
||||
|
||||
logger.info(
|
||||
"Refund token created successfully",
|
||||
extra={
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
"attempt": attempt + 1,
|
||||
"token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
return refund_token
|
||||
except Exception as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(
|
||||
"Refund token creation failed, retrying",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": max_retries,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to create refund token after all retries",
|
||||
extra={
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": max_retries,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
"mint": mint,
|
||||
},
|
||||
)
|
||||
|
||||
raise Exception(
|
||||
f"failed to create refund after {max_retries} attempts: {str(last_exception)}"
|
||||
)
|
||||
|
||||
def _calculate_refund_amount(self, amount: int, unit: str, cost_msats: int) -> int:
|
||||
"""Calculate refund amount based on unit and cost."""
|
||||
if unit == "msat":
|
||||
return amount - cost_msats
|
||||
elif unit == "sat":
|
||||
return amount - (cost_msats + 999) // 1000
|
||||
else:
|
||||
raise ValueError(f"Invalid unit: {unit}")
|
||||
|
||||
async def _handle_upstream_error(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
mint: str | None,
|
||||
api_type: str = "API",
|
||||
) -> Response:
|
||||
"""Handle upstream service errors with refund."""
|
||||
logger.warning(
|
||||
f"Upstream {api_type} request failed, processing refund",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"amount": amount,
|
||||
"unit": unit,
|
||||
},
|
||||
)
|
||||
|
||||
refund_token = await self.send_refund(amount - 60, unit, mint)
|
||||
|
||||
logger.info(
|
||||
f"Refund processed for failed upstream {api_type} request",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"refund_amount": amount,
|
||||
"unit": unit,
|
||||
"refund_token_preview": refund_token[:20] + "..."
|
||||
if len(refund_token) > 20
|
||||
else refund_token,
|
||||
},
|
||||
)
|
||||
|
||||
error_response = Response(
|
||||
content=json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"message": f"Error forwarding {api_type} request to upstream",
|
||||
"type": "upstream_error",
|
||||
"code": response.status_code,
|
||||
"refund_token": refund_token,
|
||||
}
|
||||
}
|
||||
),
|
||||
status_code=response.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
error_response.headers["X-Cashu"] = refund_token
|
||||
return error_response
|
||||
|
||||
|
||||
class ChatCompletionCashuHandler(BaseCashuHandler):
|
||||
"""Handler for X-Cashu paid chat completion requests."""
|
||||
|
||||
async def handle_request(
|
||||
self,
|
||||
request: Request,
|
||||
x_cashu_token: str,
|
||||
path: str,
|
||||
headers: dict,
|
||||
max_cost_for_model: int,
|
||||
model_obj: Model,
|
||||
prepare_request_body_func: Callable,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
query_params_func: Callable,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle chat completion request with X-Cashu payment."""
|
||||
try:
|
||||
amount, unit, mint = await validate_and_redeem_token(x_cashu_token, request)
|
||||
|
||||
# Forward request to upstream
|
||||
request_body = await request.body()
|
||||
response = await self.http_forwarder.forward_request(
|
||||
request=request,
|
||||
path=path,
|
||||
headers=headers,
|
||||
request_body=request_body,
|
||||
query_params=query_params_func(path, request.query_params),
|
||||
transform_body_func=prepare_request_body_func,
|
||||
model_obj=model_obj,
|
||||
)
|
||||
|
||||
# Handle upstream errors
|
||||
if response.status_code != 200:
|
||||
return await self._handle_upstream_error(
|
||||
response, amount, unit, mint, "chat completion"
|
||||
)
|
||||
|
||||
# Process chat completion response
|
||||
if path.endswith("chat/completions"):
|
||||
result = await self._handle_chat_completion_response(
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
get_x_cashu_cost_func,
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
result.background = background_tasks
|
||||
return result
|
||||
|
||||
# Default streaming response for other endpoints
|
||||
return self._create_default_streaming_response(response)
|
||||
|
||||
except CashuTokenError as e:
|
||||
return create_cashu_error_response(e, request, x_cashu_token)
|
||||
|
||||
async def _handle_chat_completion_response(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle chat completion response processing."""
|
||||
content = await response.aread()
|
||||
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
|
||||
|
||||
is_streaming = ChatCompletionProcessor.is_streaming_response(content_str)
|
||||
|
||||
if is_streaming:
|
||||
return await self._handle_streaming_response(
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
get_x_cashu_cost_func,
|
||||
)
|
||||
else:
|
||||
return await self._handle_non_streaming_response(
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
get_x_cashu_cost_func,
|
||||
)
|
||||
|
||||
async def _handle_streaming_response(
|
||||
self,
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming chat completion response with refund calculation."""
|
||||
response_headers = ChatCompletionProcessor.clean_response_headers(
|
||||
dict(response.headers)
|
||||
)
|
||||
|
||||
usage_data, model = ChatCompletionProcessor.extract_usage_from_streaming(
|
||||
content_str
|
||||
)
|
||||
|
||||
if usage_data and model:
|
||||
try:
|
||||
response_data = {"usage": usage_data, "model": model}
|
||||
cost_data = await get_x_cashu_cost_func(
|
||||
response_data, max_cost_for_model
|
||||
)
|
||||
|
||||
if cost_data:
|
||||
refund_amount = self._calculate_refund_amount(
|
||||
amount, unit, cost_data.total_msats
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating cost for streaming response",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
lines = content_str.strip().split("\n")
|
||||
return StreamingResponse(
|
||||
ChatCompletionProcessor.create_streaming_generator(lines),
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
async def _handle_non_streaming_response(
|
||||
self,
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
) -> Response:
|
||||
"""Handle non-streaming chat completion response with refund calculation."""
|
||||
response_headers = ChatCompletionProcessor.clean_response_headers(
|
||||
dict(response.headers)
|
||||
)
|
||||
|
||||
try:
|
||||
response_json = json.loads(content_str)
|
||||
cost_data = await get_x_cashu_cost_func(response_json, max_cost_for_model)
|
||||
|
||||
if cost_data:
|
||||
refund_amount = self._calculate_refund_amount(
|
||||
amount, unit, cost_data.total_msats
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Emergency refund on parse error
|
||||
emergency_refund = amount
|
||||
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued due to JSON parse error",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"refund_amount": emergency_refund,
|
||||
"deduction": 60,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
def _create_default_streaming_response(
|
||||
self, response: httpx.Response
|
||||
) -> StreamingResponse:
|
||||
"""Create default streaming response for non-chat endpoints."""
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
|
||||
return StreamingResponse(
|
||||
response.aiter_bytes(),
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
background=background_tasks,
|
||||
)
|
||||
|
||||
|
||||
class ResponsesApiCashuHandler(BaseCashuHandler):
|
||||
"""Handler for X-Cashu paid Responses API requests."""
|
||||
|
||||
async def handle_request(
|
||||
self,
|
||||
request: Request,
|
||||
x_cashu_token: str,
|
||||
path: str,
|
||||
headers: dict,
|
||||
max_cost_for_model: int,
|
||||
model_obj: Model,
|
||||
prepare_responses_api_request_body_func: Callable,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
query_params_func: Callable,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle Responses API request with X-Cashu payment."""
|
||||
try:
|
||||
amount, unit, mint = await validate_and_redeem_token(x_cashu_token, request)
|
||||
|
||||
# Forward request to upstream
|
||||
request_body = await request.body()
|
||||
response = await self.http_forwarder.forward_request(
|
||||
request=request,
|
||||
path=path,
|
||||
headers=headers,
|
||||
request_body=request_body,
|
||||
query_params=query_params_func(path, request.query_params),
|
||||
transform_body_func=prepare_responses_api_request_body_func,
|
||||
model_obj=model_obj,
|
||||
)
|
||||
|
||||
# Handle upstream errors
|
||||
if response.status_code != 200:
|
||||
return await self._handle_upstream_error(
|
||||
response, amount, unit, mint, "Responses API"
|
||||
)
|
||||
|
||||
# Process Responses API response
|
||||
if path.startswith("responses"):
|
||||
result = await self._handle_responses_api_completion(
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
get_x_cashu_cost_func,
|
||||
)
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
result.background = background_tasks
|
||||
return result
|
||||
|
||||
# Default streaming response for other endpoints
|
||||
return self._create_default_streaming_response(response)
|
||||
|
||||
except CashuTokenError as e:
|
||||
return create_cashu_error_response(e, request, x_cashu_token)
|
||||
|
||||
async def _handle_responses_api_completion(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
) -> Response | StreamingResponse:
|
||||
"""Handle Responses API completion response processing."""
|
||||
content = await response.aread()
|
||||
content_str = content.decode("utf-8") if isinstance(content, bytes) else content
|
||||
|
||||
is_streaming = ResponsesApiProcessor.is_streaming_response(content_str)
|
||||
|
||||
if is_streaming:
|
||||
return await self._handle_streaming_responses_api_response(
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
get_x_cashu_cost_func,
|
||||
)
|
||||
else:
|
||||
return await self._handle_non_streaming_responses_api_response(
|
||||
content_str,
|
||||
response,
|
||||
amount,
|
||||
unit,
|
||||
max_cost_for_model,
|
||||
mint,
|
||||
get_x_cashu_cost_func,
|
||||
)
|
||||
|
||||
async def _handle_streaming_responses_api_response(
|
||||
self,
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
) -> StreamingResponse:
|
||||
"""Handle streaming Responses API response with refund calculation."""
|
||||
response_headers = ResponsesApiProcessor.clean_response_headers(
|
||||
dict(response.headers)
|
||||
)
|
||||
|
||||
# Extract usage data (ignoring reasoning tokens as per requirement)
|
||||
usage_data, model = ResponsesApiProcessor.extract_usage_with_reasoning_tokens(
|
||||
content_str
|
||||
)
|
||||
|
||||
if usage_data and model:
|
||||
try:
|
||||
response_data = {"usage": usage_data, "model": model}
|
||||
cost_data = await get_x_cashu_cost_func(
|
||||
response_data, max_cost_for_model
|
||||
)
|
||||
|
||||
if cost_data:
|
||||
refund_amount = self._calculate_refund_amount(
|
||||
amount, unit, cost_data.total_msats
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error calculating cost for streaming Responses API response",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
lines = content_str.strip().split("\n")
|
||||
return StreamingResponse(
|
||||
ResponsesApiProcessor.create_streaming_generator(lines),
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="text/plain",
|
||||
)
|
||||
|
||||
async def _handle_non_streaming_responses_api_response(
|
||||
self,
|
||||
content_str: str,
|
||||
response: httpx.Response,
|
||||
amount: int,
|
||||
unit: str,
|
||||
max_cost_for_model: int,
|
||||
mint: str | None,
|
||||
get_x_cashu_cost_func: Callable,
|
||||
) -> Response:
|
||||
"""Handle non-streaming Responses API response with refund calculation."""
|
||||
response_headers = ResponsesApiProcessor.clean_response_headers(
|
||||
dict(response.headers)
|
||||
)
|
||||
|
||||
try:
|
||||
response_json = json.loads(content_str)
|
||||
cost_data = await get_x_cashu_cost_func(response_json, max_cost_for_model)
|
||||
|
||||
if cost_data:
|
||||
refund_amount = self._calculate_refund_amount(
|
||||
amount, unit, cost_data.total_msats
|
||||
)
|
||||
|
||||
if refund_amount > 0:
|
||||
refund_token = await self.send_refund(refund_amount, unit, mint)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# Emergency refund on parse error
|
||||
emergency_refund = amount
|
||||
refund_token = await send_token(emergency_refund, unit=unit, mint_url=mint)
|
||||
response_headers["X-Cashu"] = refund_token
|
||||
|
||||
logger.warning(
|
||||
"Emergency refund issued for Responses API due to JSON parse error",
|
||||
extra={
|
||||
"original_amount": amount,
|
||||
"refund_amount": emergency_refund,
|
||||
"deduction": 60,
|
||||
},
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=content_str,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
def _create_default_streaming_response(
|
||||
self, response: httpx.Response
|
||||
) -> StreamingResponse:
|
||||
"""Create default streaming response for non-responses endpoints."""
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
|
||||
return StreamingResponse(
|
||||
response.aiter_bytes(),
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
background=background_tasks,
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -7,6 +8,7 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from ..core.settings import Settings
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core import get_logger
|
||||
from ..core.db import AsyncSession, ModelRow, UpstreamProviderRow, create_session
|
||||
@@ -145,6 +147,17 @@ async def refresh_upstreams_models_periodically(
|
||||
f"Error refreshing models for {upstream.base_url}",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
try:
|
||||
from ..payment.models import _update_sats_pricing_once
|
||||
|
||||
await _update_sats_pricing_once()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to update pricing after model refresh: {e}")
|
||||
from ..proxy import refresh_model_maps
|
||||
|
||||
await refresh_model_maps()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -166,8 +179,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
Seeds database with providers from settings if empty, then loads and instantiates
|
||||
provider instances from database records, and refreshes their models cache.
|
||||
"""
|
||||
from sqlmodel import select
|
||||
|
||||
from ..core.settings import settings
|
||||
|
||||
async with create_session() as session:
|
||||
@@ -183,16 +194,16 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
result = await session.exec(select(UpstreamProviderRow))
|
||||
existing_providers = result.all()
|
||||
|
||||
upstreams: list[BaseUpstreamProvider] = []
|
||||
for provider_row in existing_providers:
|
||||
async def _init_single_provider(
|
||||
provider_row: UpstreamProviderRow,
|
||||
) -> BaseUpstreamProvider | None:
|
||||
if not provider_row.enabled:
|
||||
logger.debug(f"Skipping disabled provider: {provider_row.base_url}")
|
||||
continue
|
||||
return None
|
||||
|
||||
provider = _instantiate_provider(provider_row)
|
||||
if provider:
|
||||
await provider.refresh_models_cache()
|
||||
upstreams.append(provider)
|
||||
logger.debug(
|
||||
f"Initialized {provider_row.provider_type} provider",
|
||||
extra={
|
||||
@@ -200,6 +211,12 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
||||
"models_cached": len(provider.get_cached_models()),
|
||||
},
|
||||
)
|
||||
return provider
|
||||
return None
|
||||
|
||||
tasks = [_init_single_provider(row) for row in existing_providers]
|
||||
results = await asyncio.gather(*tasks)
|
||||
upstreams = [p for p in results if p is not None]
|
||||
|
||||
return upstreams
|
||||
|
||||
|
||||
@@ -50,7 +50,13 @@ class OpenRouterUpstreamProvider(BaseUpstreamProvider):
|
||||
async def fetch_models(self) -> list[Model]:
|
||||
"""Fetch all OpenRouter models."""
|
||||
models_data = await async_fetch_openrouter_models()
|
||||
return [Model(**model) for model in models_data] # type: ignore
|
||||
models = [Model(**model) for model in models_data] # type: ignore
|
||||
# manual alias for openai/text-embedding-ada-002 due to openrouter api bug
|
||||
for model in models:
|
||||
if model.id == "openai/text-embedding-ada-002":
|
||||
model.alias_ids = ["text-embedding-ada-002-v2"]
|
||||
break
|
||||
return models
|
||||
|
||||
async def get_balance(self) -> float | None:
|
||||
"""Get the current account balance from OpenRouter.
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""X-Cashu payment handling utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from ...core import get_logger
|
||||
from ...payment.helpers import create_error_response
|
||||
from ...wallet import recieve_token
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CashuTokenError(Exception):
|
||||
"""Exception raised for X-Cashu token processing errors."""
|
||||
|
||||
def __init__(self, error_type: str, message: str, status_code: int = 400):
|
||||
self.error_type = error_type
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
async def validate_and_redeem_token(
|
||||
x_cashu_token: str, request: Request
|
||||
) -> tuple[int, str, str | None]:
|
||||
"""Validate and redeem X-Cashu token.
|
||||
|
||||
Args:
|
||||
x_cashu_token: The X-Cashu token to validate and redeem
|
||||
request: Original FastAPI request (for error responses)
|
||||
|
||||
Returns:
|
||||
Tuple of (amount, unit, mint_url)
|
||||
|
||||
Raises:
|
||||
CashuTokenError: If token validation or redemption fails
|
||||
"""
|
||||
logger.info(
|
||||
"Processing X-Cashu token redemption",
|
||||
extra={
|
||||
"token_preview": x_cashu_token[:20] + "..."
|
||||
if len(x_cashu_token) > 20
|
||||
else x_cashu_token,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
amount, unit, mint = await recieve_token(x_cashu_token)
|
||||
logger.info(
|
||||
"X-Cashu token redeemed successfully",
|
||||
extra={"amount": amount, "unit": unit, "mint": mint},
|
||||
)
|
||||
return amount, unit, mint
|
||||
except Exception as e:
|
||||
error_message = str(e)
|
||||
logger.error(
|
||||
"X-Cashu token redemption failed",
|
||||
extra={
|
||||
"error": error_message,
|
||||
"error_type": type(e).__name__,
|
||||
},
|
||||
)
|
||||
|
||||
# Determine specific error type
|
||||
if "already spent" in error_message.lower():
|
||||
raise CashuTokenError(
|
||||
"token_already_spent",
|
||||
"The provided CASHU token has already been spent",
|
||||
400,
|
||||
)
|
||||
elif "invalid token" in error_message.lower():
|
||||
raise CashuTokenError(
|
||||
"invalid_token", "The provided CASHU token is invalid", 400
|
||||
)
|
||||
elif "mint error" in error_message.lower():
|
||||
raise CashuTokenError(
|
||||
"mint_error", f"CASHU mint error: {error_message}", 422
|
||||
)
|
||||
else:
|
||||
raise CashuTokenError(
|
||||
"cashu_error", f"CASHU token processing failed: {error_message}", 400
|
||||
)
|
||||
|
||||
|
||||
def create_cashu_error_response(
|
||||
error: CashuTokenError, request: Request, x_cashu_token: str
|
||||
) -> Response:
|
||||
"""Create error response for X-Cashu token errors.
|
||||
|
||||
Args:
|
||||
error: The CashuTokenError that occurred
|
||||
request: Original FastAPI request
|
||||
x_cashu_token: The token that caused the error
|
||||
|
||||
Returns:
|
||||
Error response with appropriate status code and message
|
||||
"""
|
||||
return create_error_response(
|
||||
error.error_type,
|
||||
error.message,
|
||||
error.status_code,
|
||||
request=request,
|
||||
token=x_cashu_token,
|
||||
)
|
||||
@@ -1,200 +0,0 @@
|
||||
"""HTTP client utilities for upstream requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING, Callable, Mapping
|
||||
|
||||
import httpx
|
||||
from fastapi import BackgroundTasks, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from ...core import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...payment.models import Model
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class HttpForwarder:
|
||||
"""Handles HTTP request forwarding to upstream services."""
|
||||
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
|
||||
def _prepare_path(self, path: str) -> str:
|
||||
"""Prepare path by removing v1/ prefix if present."""
|
||||
if path.startswith("v1/"):
|
||||
path = path.replace("v1/", "")
|
||||
return path
|
||||
|
||||
async def forward_request(
|
||||
self,
|
||||
request: Request,
|
||||
path: str,
|
||||
headers: dict,
|
||||
request_body: bytes | None,
|
||||
query_params: Mapping[str, str] | None,
|
||||
transform_body_func: Callable | None = None,
|
||||
model_obj: Model | None = None,
|
||||
) -> httpx.Response:
|
||||
"""Forward HTTP request to upstream service.
|
||||
|
||||
Args:
|
||||
request: Original FastAPI request
|
||||
path: Request path
|
||||
headers: Prepared headers for upstream
|
||||
request_body: Request body bytes, if any
|
||||
query_params: Query parameters for the request
|
||||
transform_body_func: Optional function to transform request body
|
||||
model_obj: Model object for request body transformation
|
||||
|
||||
Returns:
|
||||
Response from upstream service
|
||||
|
||||
Raises:
|
||||
httpx.RequestError: If request fails
|
||||
Exception: For other unexpected errors
|
||||
"""
|
||||
path = self._prepare_path(path)
|
||||
url = f"{self.base_url}/{path}"
|
||||
|
||||
# Transform body if function provided
|
||||
transformed_body = request_body
|
||||
if transform_body_func and request_body and model_obj:
|
||||
transformed_body = transform_body_func(request_body, model_obj)
|
||||
|
||||
logger.info(
|
||||
"Forwarding request to upstream",
|
||||
extra={
|
||||
"url": url,
|
||||
"method": request.method,
|
||||
"path": path,
|
||||
"has_request_body": request_body is not None,
|
||||
},
|
||||
)
|
||||
|
||||
client = httpx.AsyncClient(
|
||||
transport=httpx.AsyncHTTPTransport(retries=1),
|
||||
timeout=None,
|
||||
)
|
||||
|
||||
try:
|
||||
if transformed_body is not None:
|
||||
response = await client.send(
|
||||
client.build_request(
|
||||
request.method,
|
||||
url,
|
||||
headers=headers,
|
||||
content=transformed_body,
|
||||
params=query_params,
|
||||
),
|
||||
stream=True,
|
||||
)
|
||||
else:
|
||||
response = await client.send(
|
||||
client.build_request(
|
||||
request.method,
|
||||
url,
|
||||
headers=headers,
|
||||
content=request.stream(),
|
||||
params=query_params,
|
||||
),
|
||||
stream=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Received upstream response",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"path": path,
|
||||
"content_type": response.headers.get("content-type", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
except httpx.RequestError as exc:
|
||||
await client.aclose()
|
||||
error_type = type(exc).__name__
|
||||
error_details = str(exc)
|
||||
|
||||
logger.error(
|
||||
"HTTP request error to upstream",
|
||||
extra={
|
||||
"error_type": error_type,
|
||||
"error_details": error_details,
|
||||
"method": request.method,
|
||||
"url": url,
|
||||
"path": path,
|
||||
"query_params": dict(request.query_params),
|
||||
},
|
||||
)
|
||||
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
error_message = "Unable to connect to upstream service"
|
||||
elif isinstance(exc, httpx.TimeoutException):
|
||||
error_message = "Upstream service request timed out"
|
||||
elif isinstance(exc, httpx.NetworkError):
|
||||
error_message = "Network error while connecting to upstream service"
|
||||
else:
|
||||
error_message = f"Error connecting to upstream service: {error_type}"
|
||||
|
||||
raise httpx.RequestError(error_message) from exc
|
||||
|
||||
except Exception as exc:
|
||||
await client.aclose()
|
||||
tb = traceback.format_exc()
|
||||
|
||||
logger.error(
|
||||
"Unexpected error in upstream forwarding",
|
||||
extra={
|
||||
"error": str(exc),
|
||||
"error_type": type(exc).__name__,
|
||||
"method": request.method,
|
||||
"url": url,
|
||||
"path": path,
|
||||
"query_params": dict(request.query_params),
|
||||
"traceback": tb,
|
||||
},
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
class StreamingResponseWrapper:
|
||||
"""Wrapper for creating streaming responses with proper cleanup."""
|
||||
|
||||
@staticmethod
|
||||
def create_streaming_response(
|
||||
response: httpx.Response,
|
||||
client: httpx.AsyncClient,
|
||||
) -> StreamingResponse:
|
||||
"""Create a streaming response with background cleanup tasks.
|
||||
|
||||
Args:
|
||||
response: httpx response to stream
|
||||
client: httpx client to clean up
|
||||
|
||||
Returns:
|
||||
StreamingResponse with background cleanup
|
||||
"""
|
||||
background_tasks = BackgroundTasks()
|
||||
background_tasks.add_task(response.aclose)
|
||||
background_tasks.add_task(client.aclose)
|
||||
|
||||
logger.debug(
|
||||
"Creating streaming response",
|
||||
extra={
|
||||
"status_code": response.status_code,
|
||||
"content_type": response.headers.get("content-type", "unknown"),
|
||||
},
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
response.aiter_bytes(),
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
background=background_tasks,
|
||||
)
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Response processing utilities for different API types."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ...core import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ResponseProcessor:
|
||||
"""Processes responses from upstream services."""
|
||||
|
||||
@staticmethod
|
||||
def is_streaming_response(content_str: str) -> bool:
|
||||
"""Determine if response content indicates streaming.
|
||||
|
||||
Args:
|
||||
content_str: Response content as string
|
||||
|
||||
Returns:
|
||||
True if response is streaming, False otherwise
|
||||
"""
|
||||
return content_str.startswith("data:") or "data:" in content_str
|
||||
|
||||
@staticmethod
|
||||
def extract_usage_from_streaming(
|
||||
content_str: str,
|
||||
) -> tuple[dict | None, str | None]:
|
||||
"""Extract usage data and model from streaming response content.
|
||||
|
||||
Args:
|
||||
content_str: Streaming response content as string
|
||||
|
||||
Returns:
|
||||
Tuple of (usage_data, model) or (None, None) if not found
|
||||
"""
|
||||
usage_data = None
|
||||
model = None
|
||||
|
||||
lines = content_str.strip().split("\n")
|
||||
for line in lines:
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data_json = json.loads(line[6:])
|
||||
if "usage" in data_json:
|
||||
usage_data = data_json["usage"]
|
||||
model = data_json.get("model")
|
||||
elif "model" in data_json and not model:
|
||||
model = data_json["model"]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return usage_data, model
|
||||
|
||||
@staticmethod
|
||||
def clean_response_headers(headers: dict) -> dict:
|
||||
"""Clean response headers by removing encoding-related headers.
|
||||
|
||||
Args:
|
||||
headers: Original response headers
|
||||
|
||||
Returns:
|
||||
Cleaned headers dict
|
||||
"""
|
||||
cleaned_headers = dict(headers)
|
||||
headers_to_remove = ["transfer-encoding", "content-encoding", "content-length"]
|
||||
|
||||
for header in headers_to_remove:
|
||||
cleaned_headers.pop(header, None)
|
||||
|
||||
return cleaned_headers
|
||||
|
||||
@staticmethod
|
||||
async def create_streaming_generator(
|
||||
lines: list[str],
|
||||
) -> AsyncGenerator[bytes, None]:
|
||||
"""Create async generator for streaming response lines.
|
||||
|
||||
Args:
|
||||
lines: List of response lines to stream
|
||||
|
||||
Yields:
|
||||
Encoded response lines
|
||||
"""
|
||||
for line in lines:
|
||||
yield (line + "\n").encode("utf-8")
|
||||
|
||||
|
||||
class ChatCompletionProcessor(ResponseProcessor):
|
||||
"""Processor specifically for chat completion responses."""
|
||||
|
||||
@staticmethod
|
||||
def extract_model_from_chunks(stored_chunks: list[bytes]) -> str | None:
|
||||
"""Extract model name from stored response chunks.
|
||||
|
||||
Args:
|
||||
stored_chunks: List of response chunks from streaming
|
||||
|
||||
Returns:
|
||||
Model name if found, None otherwise
|
||||
"""
|
||||
last_model_seen = None
|
||||
|
||||
for i in range(len(stored_chunks) - 1, -1, -1):
|
||||
chunk = stored_chunks[i]
|
||||
if not chunk:
|
||||
continue
|
||||
try:
|
||||
events = re.split(b"data: ", chunk)
|
||||
for event_data in events:
|
||||
if not event_data or event_data.strip() in (b"[DONE]", b""):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(event_data)
|
||||
if isinstance(data, dict) and data.get("model"):
|
||||
return str(data.get("model"))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Error processing chunk for model extraction",
|
||||
extra={"error": str(e), "error_type": type(e).__name__},
|
||||
)
|
||||
|
||||
return last_model_seen
|
||||
|
||||
|
||||
class ResponsesApiProcessor(ResponseProcessor):
|
||||
"""Processor specifically for Responses API responses."""
|
||||
|
||||
@staticmethod
|
||||
def extract_usage_with_reasoning_tokens(
|
||||
content_str: str,
|
||||
) -> tuple[dict | None, str | None]:
|
||||
"""Extract usage data including reasoning tokens from Responses API content.
|
||||
|
||||
Args:
|
||||
content_str: Streaming response content as string
|
||||
|
||||
Returns:
|
||||
Tuple of (usage_data, model) with reasoning tokens preserved
|
||||
"""
|
||||
usage_data = None
|
||||
model = None
|
||||
|
||||
lines = content_str.strip().split("\n")
|
||||
for line in lines:
|
||||
if line.startswith("data: "):
|
||||
try:
|
||||
data_json = json.loads(line[6:])
|
||||
if "usage" in data_json:
|
||||
usage_data = data_json["usage"]
|
||||
model = data_json.get("model")
|
||||
# Note: We now only track input_tokens and output_tokens
|
||||
# reasoning_tokens are ignored per the requirement
|
||||
break
|
||||
elif "model" in data_json and not model:
|
||||
model = data_json["model"]
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return usage_data, model
|
||||
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_embeddings_endpoint(authenticated_client: AsyncClient) -> None:
|
||||
"""Test the embeddings endpoint proxy functionality"""
|
||||
|
||||
test_payload = {
|
||||
"model": "text-embedding-ada-002",
|
||||
"input": "The quick brown fox",
|
||||
}
|
||||
|
||||
mock_response_data = {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "embedding": [0.0023, -0.0012, 0.0045], "index": 0}
|
||||
],
|
||||
"model": "text-embedding-ada-002",
|
||||
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
# Create a proper async generator for iter_bytes
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield json.dumps(mock_response_data).encode()
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.text = json.dumps(mock_response_data)
|
||||
# Use MagicMock for synchronous .json() method
|
||||
mock_response.json = MagicMock(return_value=mock_response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
# Make POST request to embeddings endpoint
|
||||
response = await authenticated_client.post("/v1/embeddings", json=test_payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
response_data = response.json()
|
||||
assert response_data["object"] == "list"
|
||||
assert len(response_data["data"]) == 1
|
||||
assert response_data["data"][0]["object"] == "embedding"
|
||||
|
||||
# Verify request was forwarded
|
||||
mock_send.assert_called_once()
|
||||
forwarded_request = mock_send.call_args[0][0]
|
||||
# Verify the path ends with embeddings
|
||||
# Note: forwarded path might be full URL
|
||||
assert str(forwarded_request.url).endswith("embeddings")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_case_insensitivity(authenticated_client: AsyncClient) -> None:
|
||||
"""Test that model lookups are case insensitive"""
|
||||
|
||||
# We'll use a mixed-case model ID that should match the lowercase one in the system
|
||||
# We assume 'gpt-3.5-turbo' is available in the mock env/database
|
||||
|
||||
test_payload = {
|
||||
"model": "GPT-3.5-TURBO",
|
||||
"messages": [{"role": "user", "content": "Hello"}],
|
||||
}
|
||||
|
||||
with patch("httpx.AsyncClient.send") as mock_send:
|
||||
mock_response_data = {
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"choices": [{"message": {"content": "Hi"}}],
|
||||
"usage": {"total_tokens": 10},
|
||||
}
|
||||
|
||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
||||
yield json.dumps(mock_response_data).encode()
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.headers = {"content-type": "application/json"}
|
||||
mock_response.text = json.dumps(mock_response_data)
|
||||
mock_response.json = MagicMock(return_value=mock_response_data)
|
||||
mock_response.iter_bytes = mock_iter_bytes
|
||||
mock_response.aiter_bytes = mock_iter_bytes
|
||||
mock_send.return_value = mock_response
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/v1/chat/completions", json=test_payload
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -3,7 +3,6 @@ Integration tests for wallet authentication system including API key generation
|
||||
Tests POST /v1/wallet/topup endpoint and authorization header validation.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -15,7 +14,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -389,69 +387,6 @@ async def test_api_key_with_expiry_time(
|
||||
# The expiry time and refund address functionality is tested elsewhere
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_token_submissions(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test concurrent submissions of different tokens"""
|
||||
|
||||
# Generate multiple unique tokens with known amounts
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
expected_balances = {}
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
# Store expected balance by token hash
|
||||
hashed_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
expected_balances[hashed_key] = amount * 1000 # msats
|
||||
|
||||
# Create concurrent requests
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
assert len(responses) == num_tokens
|
||||
api_keys = set()
|
||||
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
api_key = data["api_key"]
|
||||
api_keys.add(api_key)
|
||||
|
||||
# Verify balance matches the expected amount
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
assert data["balance"] == expected_balances[hashed_key]
|
||||
|
||||
# Should have created unique API keys
|
||||
assert len(api_keys) == num_tokens
|
||||
|
||||
# Verify all keys exist in database
|
||||
for api_key in api_keys:
|
||||
hashed_key = api_key[3:] # Remove "sk-" prefix
|
||||
result = await integration_session.execute(
|
||||
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
|
||||
)
|
||||
db_key = result.scalar_one()
|
||||
assert db_key.balance == expected_balances[hashed_key]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorization_with_cashu_token_directly(
|
||||
@@ -504,48 +439,6 @@ async def test_x_cashu_header_support(
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.slow
|
||||
async def test_api_key_consistency_under_load(
|
||||
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
|
||||
) -> None:
|
||||
"""Test API key generation consistency under concurrent load"""
|
||||
|
||||
# Generate a single token
|
||||
token = await testmint_wallet.mint_tokens(1000)
|
||||
|
||||
# First request to create the API key
|
||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
||||
initial_response = await integration_client.get("/v1/wallet/info")
|
||||
assert initial_response.status_code == 200
|
||||
expected_api_key = initial_response.json()["api_key"]
|
||||
expected_balance = initial_response.json()["balance"]
|
||||
|
||||
# Try to use the same token concurrently multiple times
|
||||
# All should return the same API key since it's already created
|
||||
requests = [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "/v1/wallet/info",
|
||||
"headers": {"Authorization": f"Bearer {token}"},
|
||||
}
|
||||
for _ in range(20) # 20 concurrent attempts
|
||||
]
|
||||
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed and return the same API key
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == expected_api_key
|
||||
assert data["balance"] == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_database_timestamp_accuracy(
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlmodel import select, update
|
||||
|
||||
from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import ConcurrencyTester, ResponseValidator
|
||||
from .utils import ResponseValidator
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -204,45 +204,6 @@ async def test_expired_api_key_behavior(
|
||||
assert db_key.refund_address == "test@lightning.address"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_access_same_api_key(
|
||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test concurrent access with the same API key"""
|
||||
|
||||
# Get the API key from authenticated client
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Create multiple concurrent requests
|
||||
requests = []
|
||||
for i in range(20):
|
||||
# Alternate between both endpoints
|
||||
endpoint = "/v1/wallet/" if i % 2 == 0 else "/v1/wallet/info"
|
||||
requests.append(
|
||||
{
|
||||
"method": "GET",
|
||||
"url": endpoint,
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
)
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=10
|
||||
)
|
||||
|
||||
# All should succeed with consistent data
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["api_key"] == api_key
|
||||
assert data["balance"] == initial_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_wallet_info_data_consistency(
|
||||
|
||||
@@ -15,7 +15,6 @@ from routstr.core.db import ApiKey
|
||||
|
||||
from .utils import (
|
||||
CashuTokenGenerator,
|
||||
ConcurrencyTester,
|
||||
ResponseValidator,
|
||||
)
|
||||
|
||||
@@ -284,60 +283,6 @@ async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_topups_same_api_key( # type: ignore[no-untyped-def]
|
||||
integration_client: AsyncClient,
|
||||
authenticated_client: AsyncClient,
|
||||
testmint_wallet: Any,
|
||||
) -> None:
|
||||
"""Test concurrent top-ups to the same API key"""
|
||||
|
||||
# Get API key
|
||||
response = await authenticated_client.get("/v1/wallet/")
|
||||
api_key = response.json()["api_key"]
|
||||
initial_balance = response.json()["balance"]
|
||||
|
||||
# Generate multiple unique tokens
|
||||
num_tokens = 10
|
||||
tokens = []
|
||||
total_amount = 0
|
||||
|
||||
for i in range(num_tokens):
|
||||
amount = 100 + i * 10 # Different amounts
|
||||
token = await testmint_wallet.mint_tokens(amount)
|
||||
tokens.append(token)
|
||||
total_amount += amount
|
||||
|
||||
# Create concurrent top-up requests
|
||||
requests = [
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "/v1/wallet/topup",
|
||||
"params": {"cashu_token": token},
|
||||
"headers": {"Authorization": f"Bearer {api_key}"},
|
||||
}
|
||||
for token in tokens
|
||||
]
|
||||
|
||||
# Execute concurrently
|
||||
tester = ConcurrencyTester()
|
||||
responses = await tester.run_concurrent_requests(
|
||||
integration_client, requests, max_concurrent=5
|
||||
)
|
||||
|
||||
# All should succeed
|
||||
for response in responses:
|
||||
assert response.status_code == 200
|
||||
assert "msats" in response.json()
|
||||
|
||||
# Verify final balance is correct
|
||||
final_response = await authenticated_client.get("/v1/wallet/")
|
||||
final_balance = final_response.json()["balance"]
|
||||
expected_balance = initial_balance + (total_amount * 1000)
|
||||
assert final_balance == expected_balance
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]
|
||||
|
||||
@@ -41,6 +41,10 @@ export function ModelSearchFilter({
|
||||
model.name.toLowerCase().includes(query) ||
|
||||
model.full_name.toLowerCase().includes(query) ||
|
||||
model.provider.toLowerCase().includes(query) ||
|
||||
(model.alias_ids &&
|
||||
model.alias_ids.some((alias) =>
|
||||
alias.toLowerCase().includes(query)
|
||||
)) ||
|
||||
(model.description &&
|
||||
model.description.toLowerCase().includes(query)) ||
|
||||
model.modelType.toLowerCase().includes(query)
|
||||
|
||||
@@ -542,6 +542,7 @@ export function ModelSelector({
|
||||
top_provider: null,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: model.isEnabled,
|
||||
alias_ids: model.alias_ids || null,
|
||||
};
|
||||
|
||||
setModelDialogState({
|
||||
@@ -585,6 +586,7 @@ export function ModelSelector({
|
||||
top_provider: null,
|
||||
upstream_provider_id: providerId,
|
||||
enabled: model.isEnabled,
|
||||
alias_ids: model.alias_ids || null,
|
||||
};
|
||||
|
||||
setModelDialogState({
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ModelSchema = z.object({
|
||||
// API key type indicators
|
||||
has_own_api_key: z.boolean(),
|
||||
api_key_type: z.string(), // "individual" or "group"
|
||||
alias_ids: z.array(z.string()).nullable().optional(),
|
||||
});
|
||||
|
||||
// Schema for a model with additional provider-specific settings
|
||||
|
||||
@@ -121,6 +121,7 @@ export interface AdminModelAsModel {
|
||||
soft_deleted?: boolean;
|
||||
has_own_api_key: boolean;
|
||||
api_key_type: string;
|
||||
alias_ids?: string[] | null;
|
||||
}
|
||||
|
||||
export interface AdminModelGroup {
|
||||
@@ -207,6 +208,7 @@ export class AdminService {
|
||||
soft_deleted: !adminModel.enabled,
|
||||
has_own_api_key: false,
|
||||
api_key_type: 'group',
|
||||
alias_ids: adminModel.alias_ids,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,8 +356,9 @@ export class AdminService {
|
||||
original: data.pricing,
|
||||
converted: payload.pricing,
|
||||
});
|
||||
const model = await apiClient.patch<AdminModel>(
|
||||
`/admin/api/upstream-providers/${providerId}/models/${encodeURIComponent(modelId)}`,
|
||||
// Use the same POST endpoint for both create and update (upsert)
|
||||
const model = await apiClient.post<AdminModel>(
|
||||
`/admin/api/upstream-providers/${providerId}/models`,
|
||||
payload
|
||||
);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user