mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-02 08:26:13 +00:00
Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31898192b2 | ||
|
|
855d60b4a5 | ||
|
|
27ace348b5 | ||
|
|
73e3d34623 | ||
|
|
c8f8857f03 | ||
|
|
c9650441bb | ||
|
|
5ce9c2217f | ||
|
|
89b8488ab8 | ||
|
|
2ee917fa31 | ||
|
|
5e12a7e92d | ||
|
|
1d043cd98d | ||
|
|
39e0959fcd | ||
|
|
29be9d5b9c | ||
|
|
1ebb7d71e1 | ||
|
|
bbf1e65a5d | ||
|
|
51c3e5dcd7 | ||
|
|
788075f656 | ||
|
|
0bbcacd186 | ||
|
|
6d5b811c20 | ||
|
|
42258ae39c | ||
|
|
04d6903369 | ||
|
|
b0b2ceb1a0 | ||
|
|
9dfa58d69f | ||
|
|
22ec9c3132 | ||
|
|
b72c578954 | ||
|
|
7e299180fe | ||
|
|
f290534df4 | ||
|
|
8d3c064b29 | ||
|
|
4ac96ade5f | ||
|
|
180a469399 | ||
|
|
87fbb48ca8 | ||
|
|
3465a44d0e | ||
|
|
a4259af38f | ||
|
|
0d07dd0cdb | ||
|
|
24015ebec1 | ||
|
|
db021866d8 | ||
|
|
d4339287be | ||
|
|
88bcc0edcb | ||
|
|
917a4d32b1 | ||
|
|
daf17f51ab | ||
|
|
fc042c768c | ||
|
|
3bc38937e8 | ||
|
|
9229b87b70 | ||
|
|
cb36189db3 | ||
|
|
d2487f42b0 | ||
|
|
5255fce7b2 | ||
|
|
ee668ee93b | ||
|
|
cf8b990fc7 | ||
|
|
78dd74845b | ||
|
|
e7f4c98475 | ||
|
|
fad792068e | ||
|
|
1e2d130022 | ||
|
|
1e21dce735 | ||
|
|
21ae22abec | ||
|
|
50eabafa57 | ||
|
|
7d829af681 | ||
|
|
9e9bc5bff8 | ||
|
|
6d780ef96d | ||
|
|
b70b94b9b4 | ||
|
|
f1fa7d094f |
@@ -434,6 +434,42 @@ Authorization: Bearer sk-...
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Create Child Key
|
||||||
|
|
||||||
|
Creates one or more child API keys that share the parent's balance. Each child key creation costs a fixed amount (configurable).
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /v1/balance/child-key
|
||||||
|
Authorization: Bearer sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `count` | integer | Yes | - | Number of child keys to create (1-50) |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"api_keys": ["sk-abc...", "sk-def..."],
|
||||||
|
"count": 2,
|
||||||
|
"cost_msats": 2000,
|
||||||
|
"cost_sats": 2,
|
||||||
|
"parent_balance": 98000,
|
||||||
|
"parent_balance_sats": 98
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Provider Discovery
|
## Provider Discovery
|
||||||
|
|
||||||
## Admin Settings
|
## Admin Settings
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def create_child_keys(base_url: str, api_key: str, count: int = 3) -> list[str]:
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"}
|
||||||
|
|
||||||
|
print(f"Requesting {count} child keys from {base_url}...")
|
||||||
|
|
||||||
|
child_keys = []
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
try:
|
||||||
|
response = httpx.post(f"{base_url}/v1/balance/child-key", headers=headers)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
child_keys.append(data["api_key"])
|
||||||
|
print(
|
||||||
|
f" [{i + 1}] Created: {data['api_key']} (Cost: {data['cost_msats']} msats)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f" [{i + 1}] Failed: {response.status_code} - {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [{i + 1}] Error: {str(e)}")
|
||||||
|
|
||||||
|
return child_keys
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python create_child_keys.py <api_key_or_cashu_token> [base_url]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
auth_key = sys.argv[1]
|
||||||
|
base_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:8000"
|
||||||
|
|
||||||
|
keys = create_child_keys(base_url, auth_key)
|
||||||
|
|
||||||
|
if keys:
|
||||||
|
print("\nSuccessfully created child keys:")
|
||||||
|
print(json.dumps(keys, indent=2))
|
||||||
|
else:
|
||||||
|
print("\nNo child keys were created.")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
Revision ID: a86e5348850b
|
||||||
|
Revises: b9667ffc5701
|
||||||
|
Create Date: 2026-01-10 18:57:48.475781
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "a86e5348850b"
|
||||||
|
down_revision = "b9667ffc5701"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Use batch_alter_table for SQLite compatibility
|
||||||
|
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||||
|
batch_op.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"parent_key_hash", sqlmodel.sql.sqltypes.AutoString(), nullable=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
batch_op.create_index(
|
||||||
|
batch_op.f("ix_api_keys_parent_key_hash"), ["parent_key_hash"], unique=False
|
||||||
|
)
|
||||||
|
batch_op.create_foreign_key(
|
||||||
|
"fk_api_keys_parent_key_hash",
|
||||||
|
"api_keys",
|
||||||
|
["parent_key_hash"],
|
||||||
|
["hashed_key"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("api_keys", schema=None) as batch_op:
|
||||||
|
batch_op.drop_constraint("fk_api_keys_parent_key_hash", type_="foreignkey")
|
||||||
|
batch_op.drop_index(batch_op.f("ix_api_keys_parent_key_hash"))
|
||||||
|
batch_op.drop_column("parent_key_hash")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""make upstream provider base_url + api_key unique
|
||||||
|
|
||||||
|
Revision ID: c2d3e4f5a6b7
|
||||||
|
Revises: a86e5348850b
|
||||||
|
Create Date: 2026-01-25 00:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "c2d3e4f5a6b7"
|
||||||
|
down_revision = "a86e5348850b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _recreate_table_sqlite(add_base_url_unique: bool) -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
existing_tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.exec_driver_sql(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if "upstream_providers_old" in existing_tables:
|
||||||
|
if "upstream_providers" in existing_tables:
|
||||||
|
op.drop_table("upstream_providers_old")
|
||||||
|
else:
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE upstream_providers_old RENAME TO upstream_providers"
|
||||||
|
)
|
||||||
|
existing_tables.add("upstream_providers")
|
||||||
|
if "upstream_providers" not in existing_tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
constraints = [
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"base_url",
|
||||||
|
"api_key",
|
||||||
|
name="uq_upstream_providers_base_url_api_key",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if add_base_url_unique:
|
||||||
|
constraints.append(
|
||||||
|
sa.UniqueConstraint("base_url", name="uq_upstream_providers_base_url")
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE upstream_providers RENAME TO upstream_providers_old")
|
||||||
|
op.create_table(
|
||||||
|
"upstream_providers",
|
||||||
|
sa.Column(
|
||||||
|
"id", sa.Integer(), primary_key=True, nullable=False, autoincrement=True
|
||||||
|
),
|
||||||
|
sa.Column("provider_type", sa.String(), nullable=False),
|
||||||
|
sa.Column("base_url", sa.String(), nullable=False),
|
||||||
|
sa.Column("api_key", sa.String(), nullable=False),
|
||||||
|
sa.Column("api_version", sa.String(), nullable=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("provider_fee", sa.Float(), nullable=False, server_default="1.01"),
|
||||||
|
*constraints,
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO upstream_providers (id, provider_type, base_url, api_key, api_version, enabled, provider_fee) "
|
||||||
|
"SELECT id, provider_type, base_url, api_key, api_version, enabled, provider_fee "
|
||||||
|
"FROM upstream_providers_old"
|
||||||
|
)
|
||||||
|
op.drop_table("upstream_providers_old")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
_recreate_table_sqlite(add_base_url_unique=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
for constraint in inspector.get_unique_constraints("upstream_providers"):
|
||||||
|
name = constraint.get("name")
|
||||||
|
if constraint.get("column_names") == ["base_url"] and name:
|
||||||
|
op.drop_constraint(
|
||||||
|
name,
|
||||||
|
"upstream_providers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
index_names = {idx["name"] for idx in inspector.get_indexes("upstream_providers")}
|
||||||
|
if "ix_upstream_providers_base_url" in index_names:
|
||||||
|
op.drop_index("ix_upstream_providers_base_url", table_name="upstream_providers")
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_upstream_providers_base_url_api_key",
|
||||||
|
"upstream_providers",
|
||||||
|
["base_url", "api_key"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if conn.dialect.name == "sqlite":
|
||||||
|
_recreate_table_sqlite(add_base_url_unique=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
op.drop_constraint(
|
||||||
|
"uq_upstream_providers_base_url_api_key",
|
||||||
|
"upstream_providers",
|
||||||
|
type_="unique",
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_upstream_providers_base_url",
|
||||||
|
"upstream_providers",
|
||||||
|
["base_url"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_upstream_providers_base_url",
|
||||||
|
"upstream_providers",
|
||||||
|
["base_url"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "routstr"
|
name = "routstr"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
description = "Payment proxy for your LLM endpoint using cashu and nostr."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
+52
-96
@@ -84,93 +84,26 @@ def get_provider_penalty(provider: "BaseUpstreamProvider") -> float:
|
|||||||
return penalty
|
return penalty
|
||||||
|
|
||||||
|
|
||||||
def should_prefer_model(
|
|
||||||
candidate_model: "Model",
|
|
||||||
candidate_provider: "BaseUpstreamProvider",
|
|
||||||
current_model: "Model",
|
|
||||||
current_provider: "BaseUpstreamProvider",
|
|
||||||
alias: str,
|
|
||||||
) -> bool:
|
|
||||||
"""Determine if candidate model should replace current model for an alias.
|
|
||||||
|
|
||||||
This is the core decision function for model prioritization. It considers:
|
|
||||||
1. Alias matching quality (exact match vs. canonical slug match)
|
|
||||||
2. Model cost (lower is better)
|
|
||||||
3. Provider penalties (e.g., slight preference against OpenRouter)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
candidate_model: The new model being considered
|
|
||||||
candidate_provider: Provider offering the candidate model
|
|
||||||
current_model: The currently selected model for this alias
|
|
||||||
current_provider: Provider offering the current model
|
|
||||||
alias: The model alias being mapped
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if candidate should replace current, False otherwise
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_base_model_id(model_id: str) -> str:
|
|
||||||
"""Get base model ID by removing provider prefix."""
|
|
||||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
|
||||||
|
|
||||||
def alias_priority(model: "Model") -> int:
|
|
||||||
"""Rank how strong the mapping of alias->model is.
|
|
||||||
|
|
||||||
Highest priority when alias exactly equals the model ID without provider prefix.
|
|
||||||
Next when alias equals canonical slug without prefix. Otherwise lowest.
|
|
||||||
"""
|
|
||||||
model_base = get_base_model_id(model.id)
|
|
||||||
if model_base == alias:
|
|
||||||
return 3
|
|
||||||
if model.canonical_slug:
|
|
||||||
canonical_base = get_base_model_id(model.canonical_slug)
|
|
||||||
if canonical_base == alias:
|
|
||||||
return 2
|
|
||||||
return 1
|
|
||||||
|
|
||||||
candidate_alias_priority = alias_priority(candidate_model)
|
|
||||||
current_alias_priority = alias_priority(current_model)
|
|
||||||
|
|
||||||
# If candidate has better alias match, prefer it regardless of cost
|
|
||||||
if candidate_alias_priority > current_alias_priority:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# If current has better alias match, keep it regardless of cost
|
|
||||||
if current_alias_priority > candidate_alias_priority:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Same alias priority - compare costs
|
|
||||||
candidate_cost = calculate_model_cost_score(candidate_model)
|
|
||||||
current_cost = calculate_model_cost_score(current_model)
|
|
||||||
|
|
||||||
# Apply provider penalties
|
|
||||||
candidate_adjusted = candidate_cost * get_provider_penalty(candidate_provider)
|
|
||||||
current_adjusted = current_cost * get_provider_penalty(current_provider)
|
|
||||||
|
|
||||||
# Prefer lower adjusted cost
|
|
||||||
should_replace = candidate_adjusted < current_adjusted
|
|
||||||
|
|
||||||
return should_replace
|
|
||||||
|
|
||||||
|
|
||||||
def create_model_mappings(
|
def create_model_mappings(
|
||||||
upstreams: list["BaseUpstreamProvider"],
|
upstreams: list["BaseUpstreamProvider"],
|
||||||
overrides_by_id: dict[str, tuple],
|
overrides_by_id: dict[str, tuple],
|
||||||
disabled_model_ids: set[str],
|
disabled_model_ids: set[str],
|
||||||
) -> tuple[dict[str, "Model"], dict[str, "BaseUpstreamProvider"], dict[str, "Model"]]:
|
) -> tuple[
|
||||||
|
dict[str, "Model"], dict[str, list["BaseUpstreamProvider"]], dict[str, "Model"]
|
||||||
|
]:
|
||||||
"""Create optimal model mappings based on cost and provider preferences.
|
"""Create optimal model mappings based on cost and provider preferences.
|
||||||
|
|
||||||
This is the main entry point for the algorithm. It processes all upstream providers
|
This is the main entry point for the algorithm. It processes all upstream providers
|
||||||
and creates three mappings based on cost optimization:
|
and creates three mappings based on cost optimization:
|
||||||
|
|
||||||
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
|
1. model_instances: alias -> Model (all model aliases mapped to their Model objects)
|
||||||
2. provider_map: alias -> UpstreamProvider (which provider to use for each alias)
|
2. provider_map: alias -> List[UpstreamProvider] (sorted list of providers for each alias)
|
||||||
3. unique_models: base_id -> Model (unique models without provider prefixes)
|
3. unique_models: base_id -> Model (unique models without provider prefixes)
|
||||||
|
|
||||||
The algorithm:
|
The algorithm:
|
||||||
- Processes non-OpenRouter providers first (they're typically cheaper)
|
- Processes non-OpenRouter providers first (they're typically cheaper)
|
||||||
- Then processes OpenRouter models (they can still win if cheaper)
|
- Then processes OpenRouter models (they can still win if cheaper)
|
||||||
- For each model alias, uses should_prefer_model() to select the best provider
|
- For each model alias, collects all candidates and sorts them by priority and cost.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
upstreams: List of all upstream provider instances
|
upstreams: List of all upstream provider instances
|
||||||
@@ -183,8 +116,7 @@ def create_model_mappings(
|
|||||||
from .payment.models import _row_to_model
|
from .payment.models import _row_to_model
|
||||||
from .upstream.helpers import resolve_model_alias
|
from .upstream.helpers import resolve_model_alias
|
||||||
|
|
||||||
model_instances: dict[str, "Model"] = {}
|
candidates: dict[str, list[tuple["Model", "BaseUpstreamProvider"]]] = {}
|
||||||
provider_map: dict[str, "BaseUpstreamProvider"] = {}
|
|
||||||
unique_models: dict[str, "Model"] = {}
|
unique_models: dict[str, "Model"] = {}
|
||||||
|
|
||||||
# Separate OpenRouter from other providers
|
# Separate OpenRouter from other providers
|
||||||
@@ -202,24 +134,14 @@ def create_model_mappings(
|
|||||||
"""Get base model ID by removing provider prefix."""
|
"""Get base model ID by removing provider prefix."""
|
||||||
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
return model_id.split("/", 1)[1] if "/" in model_id else model_id
|
||||||
|
|
||||||
def _maybe_set_alias(
|
def _add_candidate(
|
||||||
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
alias: str, model: "Model", provider: "BaseUpstreamProvider"
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set alias to model/provider if not set or if new model is preferred."""
|
"""Add candidate model/provider for an alias."""
|
||||||
alias_lower = alias.lower()
|
alias_lower = alias.lower()
|
||||||
existing_model = model_instances.get(alias_lower)
|
if alias_lower not in candidates:
|
||||||
if not existing_model:
|
candidates[alias_lower] = []
|
||||||
# No existing mapping, set it
|
candidates[alias_lower].append((model, provider))
|
||||||
model_instances[alias_lower] = model
|
|
||||||
provider_map[alias_lower] = provider
|
|
||||||
else:
|
|
||||||
# Check if candidate should replace existing
|
|
||||||
existing_provider = provider_map[alias_lower]
|
|
||||||
if should_prefer_model(
|
|
||||||
model, provider, existing_model, existing_provider, alias
|
|
||||||
):
|
|
||||||
model_instances[alias_lower] = model
|
|
||||||
provider_map[alias_lower] = provider
|
|
||||||
|
|
||||||
def process_provider_models(
|
def process_provider_models(
|
||||||
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
upstream: "BaseUpstreamProvider", is_openrouter: bool = False
|
||||||
@@ -266,21 +188,55 @@ def create_model_mappings(
|
|||||||
|
|
||||||
# Try to set each alias
|
# Try to set each alias
|
||||||
for alias in aliases:
|
for alias in aliases:
|
||||||
_maybe_set_alias(alias, model_to_use, upstream)
|
_add_candidate(alias, model_to_use, upstream)
|
||||||
|
|
||||||
# Process non-OpenRouter providers first (they're typically cheaper)
|
# Process non-OpenRouter providers first
|
||||||
for upstream in other_upstreams:
|
for upstream in other_upstreams:
|
||||||
process_provider_models(upstream, is_openrouter=False)
|
process_provider_models(upstream, is_openrouter=False)
|
||||||
|
|
||||||
# Process OpenRouter last - models only win if they're cheaper or better matched
|
# Process OpenRouter last
|
||||||
if openrouter:
|
if openrouter:
|
||||||
process_provider_models(openrouter, is_openrouter=True)
|
process_provider_models(openrouter, is_openrouter=True)
|
||||||
|
|
||||||
# Log provider distribution
|
# Sort candidates and build final maps
|
||||||
|
model_instances: dict[str, "Model"] = {}
|
||||||
|
provider_map: dict[str, list["BaseUpstreamProvider"]] = {}
|
||||||
|
|
||||||
|
def alias_priority(model: "Model", alias: str) -> int:
|
||||||
|
"""Rank how strong the mapping of alias->model is."""
|
||||||
|
model_base = get_base_model_id(model.id)
|
||||||
|
if model_base == alias:
|
||||||
|
return 3
|
||||||
|
if model.canonical_slug:
|
||||||
|
canonical_base = get_base_model_id(model.canonical_slug)
|
||||||
|
if canonical_base == alias:
|
||||||
|
return 2
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for alias, items in candidates.items():
|
||||||
|
# Sort key: (priority DESC, cost ASC)
|
||||||
|
# Using negative cost for DESC sort overall to keep high priority first
|
||||||
|
def sort_key(item: tuple["Model", "BaseUpstreamProvider"]) -> tuple[int, float]:
|
||||||
|
model, provider = item
|
||||||
|
priority = alias_priority(model, alias)
|
||||||
|
cost = calculate_model_cost_score(model)
|
||||||
|
penalty = get_provider_penalty(provider)
|
||||||
|
adjusted_cost = cost * penalty
|
||||||
|
return (priority, -adjusted_cost)
|
||||||
|
|
||||||
|
items.sort(key=sort_key, reverse=True)
|
||||||
|
|
||||||
|
best_model, best_provider = items[0]
|
||||||
|
model_instances[alias] = best_model
|
||||||
|
provider_map[alias] = [p for _, p in items]
|
||||||
|
|
||||||
|
# Log provider distribution (using top provider for stats)
|
||||||
provider_counts: dict[str, int] = {}
|
provider_counts: dict[str, int] = {}
|
||||||
for provider in provider_map.values():
|
for providers in provider_map.values():
|
||||||
provider_name = getattr(provider, "upstream_name", "unknown")
|
if providers:
|
||||||
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
provider = providers[0]
|
||||||
|
provider_name = getattr(provider, "upstream_name", "unknown")
|
||||||
|
provider_counts[provider_name] = provider_counts.get(provider_name, 0) + 1
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
f"Updated model mappings with ({len(unique_models)} unique models and {len(model_instances)} aliases)",
|
||||||
|
|||||||
+166
-40
@@ -286,30 +286,55 @@ async def validate_bearer_key(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_billing_key(key: ApiKey, session: AsyncSession) -> ApiKey:
|
||||||
|
"""Returns the key that should be charged for the request."""
|
||||||
|
if key.parent_key_hash:
|
||||||
|
parent = await session.get(ApiKey, key.parent_key_hash)
|
||||||
|
if parent:
|
||||||
|
# We want to keep the total_requests and total_spent on the child key
|
||||||
|
# but use the balance and reserved_balance of the parent.
|
||||||
|
# However, pay_for_request updates reserved_balance and total_requests.
|
||||||
|
# To stay simple, we charge the parent's balance and update parent's total_requests.
|
||||||
|
return parent
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Parent key not found for child key",
|
||||||
|
extra={
|
||||||
|
"child_key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"parent_key_hash": key.parent_key_hash[:8] + "...",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
async def pay_for_request(
|
async def pay_for_request(
|
||||||
key: ApiKey, cost_per_request: int, session: AsyncSession
|
key: ApiKey, cost_per_request: int, session: AsyncSession
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Process payment for a request."""
|
"""Process payment for a request."""
|
||||||
|
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Processing payment for request",
|
"Processing payment for request",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"current_balance": key.balance,
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"current_balance": billing_key.balance,
|
||||||
"required_cost": cost_per_request,
|
"required_cost": cost_per_request,
|
||||||
"sufficient_balance": key.balance >= cost_per_request,
|
"sufficient_balance": billing_key.balance >= cost_per_request,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if key.total_balance < cost_per_request:
|
if billing_key.total_balance < cost_per_request:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Insufficient balance for request",
|
"Insufficient balance for request",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
"balance": key.balance,
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"reserved_balance": key.reserved_balance,
|
"balance": billing_key.balance,
|
||||||
|
"reserved_balance": billing_key.reserved_balance,
|
||||||
"required": cost_per_request,
|
"required": cost_per_request,
|
||||||
"shortfall": cost_per_request - key.total_balance,
|
"shortfall": cost_per_request - billing_key.total_balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -317,7 +342,7 @@ async def pay_for_request(
|
|||||||
status_code=402,
|
status_code=402,
|
||||||
detail={
|
detail={
|
||||||
"error": {
|
"error": {
|
||||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.total_balance} available. (reserved: {key.reserved_balance})",
|
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.total_balance} available. (reserved: {billing_key.reserved_balance})",
|
||||||
"type": "insufficient_quota",
|
"type": "insufficient_quota",
|
||||||
"code": "insufficient_balance",
|
"code": "insufficient_balance",
|
||||||
}
|
}
|
||||||
@@ -328,15 +353,16 @@ async def pay_for_request(
|
|||||||
"Charging base cost for request",
|
"Charging base cost for request",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"cost": cost_per_request,
|
"cost": cost_per_request,
|
||||||
"balance_before": key.balance,
|
"balance_before": billing_key.balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Charge the base cost for the request atomically to avoid race conditions
|
# Charge the base cost for the request atomically to avoid race conditions
|
||||||
stmt = (
|
stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
.where(col(ApiKey.balance) - col(ApiKey.reserved_balance) >= cost_per_request)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
reserved_balance=col(ApiKey.reserved_balance) + cost_per_request,
|
||||||
@@ -344,6 +370,16 @@ async def pay_for_request(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# Also increment total_requests on the child key if it's different
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(total_requests=col(ApiKey.total_requests) + 1)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
@@ -351,8 +387,9 @@ async def pay_for_request(
|
|||||||
"Concurrent request depleted balance",
|
"Concurrent request depleted balance",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"required_cost": cost_per_request,
|
"required_cost": cost_per_request,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -361,23 +398,26 @@ async def pay_for_request(
|
|||||||
status_code=402,
|
status_code=402,
|
||||||
detail={
|
detail={
|
||||||
"error": {
|
"error": {
|
||||||
"message": f"Insufficient balance: {cost_per_request} mSats required. {key.balance} available.",
|
"message": f"Insufficient balance: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||||
"type": "insufficient_quota",
|
"type": "insufficient_quota",
|
||||||
"code": "insufficient_balance",
|
"code": "insufficient_balance",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Payment processed successfully",
|
"Payment processed successfully",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"charged_amount": cost_per_request,
|
"charged_amount": cost_per_request,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"total_spent": key.total_spent,
|
"total_spent": billing_key.total_spent,
|
||||||
"total_requests": key.total_requests,
|
"total_requests": billing_key.total_requests,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -387,9 +427,11 @@ async def pay_for_request(
|
|||||||
async def revert_pay_for_request(
|
async def revert_pay_for_request(
|
||||||
key: ApiKey, session: AsyncSession, cost_per_request: int
|
key: ApiKey, session: AsyncSession, cost_per_request: int
|
||||||
) -> None:
|
) -> None:
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
reserved_balance=col(ApiKey.reserved_balance) - cost_per_request,
|
||||||
total_requests=col(ApiKey.total_requests) - 1,
|
total_requests=col(ApiKey.total_requests) - 1,
|
||||||
@@ -397,27 +439,40 @@ async def revert_pay_for_request(
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await session.exec(stmt) # type: ignore[call-overload]
|
result = await session.exec(stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# Also decrement total_requests on the child key if it's different
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(total_requests=col(ApiKey.total_requests) - 1)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to revert payment - insufficient reserved balance",
|
"Failed to revert payment - insufficient reserved balance",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"cost_to_revert": cost_per_request,
|
"cost_to_revert": cost_per_request,
|
||||||
"current_reserved_balance": key.reserved_balance,
|
"current_reserved_balance": billing_key.reserved_balance,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=402,
|
status_code=402,
|
||||||
detail={
|
detail={
|
||||||
"error": {
|
"error": {
|
||||||
"message": f"failed to revert request payment: {cost_per_request} mSats required. {key.balance} available.",
|
"message": f"failed to revert request payment: {cost_per_request} mSats required. {billing_key.balance} available.",
|
||||||
"type": "payment_error",
|
"type": "payment_error",
|
||||||
"code": "payment_error",
|
"code": "payment_error",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
|
|
||||||
async def adjust_payment_for_tokens(
|
async def adjust_payment_for_tokens(
|
||||||
@@ -428,15 +483,17 @@ async def adjust_payment_for_tokens(
|
|||||||
This is called after the initial payment and the upstream request is complete.
|
This is called after the initial payment and the upstream request is complete.
|
||||||
Returns cost data to be included in the response.
|
Returns cost data to be included in the response.
|
||||||
"""
|
"""
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
model = response_data.get("model", "unknown")
|
model = response_data.get("model", "unknown")
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Starting payment adjustment for tokens",
|
"Starting payment adjustment for tokens",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
"has_usage": "usage" in response_data,
|
"has_usage": "usage" in response_data,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -446,8 +503,10 @@ async def adjust_payment_for_tokens(
|
|||||||
try:
|
try:
|
||||||
release_stmt = (
|
release_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost)
|
.values(
|
||||||
|
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost
|
||||||
|
)
|
||||||
)
|
)
|
||||||
await session.exec(release_stmt) # type: ignore[call-overload]
|
await session.exec(release_stmt) # type: ignore[call-overload]
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -455,13 +514,18 @@ async def adjust_payment_for_tokens(
|
|||||||
"Released reservation without charging (fallback)",
|
"Released reservation without charging (fallback)",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to release reservation in fallback",
|
"Failed to release reservation in fallback",
|
||||||
extra={"error": str(e), "key_hash": key.hashed_key[:8] + "..."},
|
extra={
|
||||||
|
"error": str(e),
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
match await calculate_cost(response_data, deducted_max_cost, session):
|
match await calculate_cost(response_data, deducted_max_cost, session):
|
||||||
@@ -470,6 +534,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"Using max cost data (no token adjustment)",
|
"Using max cost data (no token adjustment)",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_cost": cost.total_msats,
|
"max_cost": cost.total_msats,
|
||||||
},
|
},
|
||||||
@@ -477,7 +542,7 @@ async def adjust_payment_for_tokens(
|
|||||||
# Finalize by releasing reservation and charging max cost
|
# Finalize by releasing reservation and charging max cost
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
reserved_balance=col(ApiKey.reserved_balance) - deducted_max_cost,
|
||||||
balance=col(ApiKey.balance) - cost.total_msats,
|
balance=col(ApiKey.balance) - cost.total_msats,
|
||||||
@@ -485,27 +550,41 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# Also update total_spent on the child key if it's different
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(total_spent=col(ApiKey.total_spent) + cost.total_msats)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to finalize max-cost payment - retrying reservation release",
|
"Failed to finalize max-cost payment - retrying reservation release",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
"current_reserved_balance": key.reserved_balance,
|
"current_reserved_balance": billing_key.reserved_balance,
|
||||||
"total_cost": cost.total_msats,
|
"total_cost": cost.total_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await release_reservation_only()
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Max cost payment finalized",
|
"Max cost payment finalized",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"charged_amount": cost.total_msats,
|
"charged_amount": cost.total_msats,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -521,6 +600,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"Calculated token-based cost",
|
"Calculated token-based cost",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"model": model,
|
"model": model,
|
||||||
"token_cost": cost.total_msats,
|
"token_cost": cost.total_msats,
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
@@ -533,11 +613,15 @@ async def adjust_payment_for_tokens(
|
|||||||
if cost_difference == 0:
|
if cost_difference == 0:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Finalizing with exact reserved cost",
|
"Finalizing with exact reserved cost",
|
||||||
extra={"key_hash": key.hashed_key[:8] + "...", "model": model},
|
extra={
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
|
"model": model,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
- deducted_max_cost,
|
- deducted_max_cost,
|
||||||
@@ -546,8 +630,20 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# Also update total_spent on the child key if it's different
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
return cost.dict()
|
return cost.dict()
|
||||||
|
|
||||||
# this should never happen why do we handle this???
|
# this should never happen why do we handle this???
|
||||||
@@ -557,16 +653,17 @@ async def adjust_payment_for_tokens(
|
|||||||
"Additional charge required for token usage",
|
"Additional charge required for token usage",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"additional_charge": cost_difference,
|
"additional_charge": cost_difference,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
"sufficient_balance": key.balance >= cost_difference,
|
"sufficient_balance": billing_key.balance >= cost_difference,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
finalize_stmt = (
|
finalize_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
- deducted_max_cost,
|
- deducted_max_cost,
|
||||||
@@ -575,18 +672,31 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
result = await session.exec(finalize_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# Also update total_spent on the child key if it's different
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if result.rowcount:
|
if result.rowcount:
|
||||||
cost.total_msats = total_cost_msats
|
cost.total_msats = total_cost_msats
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Finalized payment with additional charge",
|
"Finalized payment with additional charge",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"charged_amount": total_cost_msats,
|
"charged_amount": total_cost_msats,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -595,6 +705,7 @@ async def adjust_payment_for_tokens(
|
|||||||
"Failed to finalize additional charge - releasing reservation",
|
"Failed to finalize additional charge - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"attempted_charge": total_cost_msats,
|
"attempted_charge": total_cost_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
@@ -607,15 +718,16 @@ async def adjust_payment_for_tokens(
|
|||||||
"Refunding excess payment",
|
"Refunding excess payment",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"refund_amount": refund,
|
"refund_amount": refund,
|
||||||
"current_balance": key.balance,
|
"current_balance": billing_key.balance,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
refund_stmt = (
|
refund_stmt = (
|
||||||
update(ApiKey)
|
update(ApiKey)
|
||||||
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
.where(col(ApiKey.hashed_key) == billing_key.hashed_key)
|
||||||
.values(
|
.values(
|
||||||
reserved_balance=col(ApiKey.reserved_balance)
|
reserved_balance=col(ApiKey.reserved_balance)
|
||||||
- deducted_max_cost,
|
- deducted_max_cost,
|
||||||
@@ -624,6 +736,16 @@ async def adjust_payment_for_tokens(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
result = await session.exec(refund_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
|
# Also update total_spent on the child key if it's different
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
child_stmt = (
|
||||||
|
update(ApiKey)
|
||||||
|
.where(col(ApiKey.hashed_key) == key.hashed_key)
|
||||||
|
.values(total_spent=col(ApiKey.total_spent) + total_cost_msats)
|
||||||
|
)
|
||||||
|
await session.exec(child_stmt) # type: ignore[call-overload]
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
if result.rowcount == 0:
|
if result.rowcount == 0:
|
||||||
@@ -631,8 +753,9 @@ async def adjust_payment_for_tokens(
|
|||||||
"Failed to finalize payment - releasing reservation",
|
"Failed to finalize payment - releasing reservation",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"deducted_max_cost": deducted_max_cost,
|
"deducted_max_cost": deducted_max_cost,
|
||||||
"current_reserved_balance": key.reserved_balance,
|
"current_reserved_balance": billing_key.reserved_balance,
|
||||||
"total_cost": total_cost_msats,
|
"total_cost": total_cost_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
@@ -640,14 +763,17 @@ async def adjust_payment_for_tokens(
|
|||||||
await release_reservation_only()
|
await release_reservation_only()
|
||||||
else:
|
else:
|
||||||
cost.total_msats = total_cost_msats
|
cost.total_msats = total_cost_msats
|
||||||
await session.refresh(key)
|
await session.refresh(billing_key)
|
||||||
|
if billing_key.hashed_key != key.hashed_key:
|
||||||
|
await session.refresh(key)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Refund processed successfully",
|
"Refund processed successfully",
|
||||||
extra={
|
extra={
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"billing_key_hash": billing_key.hashed_key[:8] + "...",
|
||||||
"refunded_amount": refund,
|
"refunded_amount": refund,
|
||||||
"new_balance": key.balance,
|
"new_balance": billing_key.balance,
|
||||||
"final_cost": cost.total_msats,
|
"final_cost": cost.total_msats,
|
||||||
"model": model,
|
"model": model,
|
||||||
},
|
},
|
||||||
|
|||||||
+104
-12
@@ -32,16 +32,30 @@ async def get_key_from_header(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# TODO: remove this endpoint when frontend is updated
|
async def get_balance_info(key: ApiKey, session: AsyncSession) -> dict:
|
||||||
@router.get("/", include_in_schema=False)
|
from .auth import get_billing_key
|
||||||
async def account_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
return {
|
return {
|
||||||
"api_key": "sk-" + key.hashed_key,
|
"api_key": "sk-" + key.hashed_key,
|
||||||
"balance": key.balance,
|
"balance": billing_key.balance,
|
||||||
"reserved": key.reserved_balance,
|
"reserved": billing_key.reserved_balance,
|
||||||
|
"is_child": key.parent_key_hash is not None,
|
||||||
|
"parent_key": "sk-" + key.parent_key_hash if key.parent_key_hash else None,
|
||||||
|
"total_requests": key.total_requests,
|
||||||
|
"total_spent": key.total_spent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: remove this endpoint when frontend is updated
|
||||||
|
@router.get("/", include_in_schema=False)
|
||||||
|
async def account_info(
|
||||||
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
return await get_balance_info(key, session)
|
||||||
|
|
||||||
|
|
||||||
# TODO: Implement POST /v1/wallet/create endpoint
|
# TODO: Implement POST /v1/wallet/create endpoint
|
||||||
# This endpoint should accept:
|
# This endpoint should accept:
|
||||||
# - cashu_token (required): The eCash token to deposit
|
# - cashu_token (required): The eCash token to deposit
|
||||||
@@ -66,12 +80,11 @@ async def create_balance(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/info")
|
@router.get("/info")
|
||||||
async def wallet_info(key: ApiKey = Depends(get_key_from_header)) -> dict:
|
async def wallet_info(
|
||||||
return {
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
"api_key": "sk-" + key.hashed_key,
|
session: AsyncSession = Depends(get_session),
|
||||||
"balance": key.balance,
|
) -> dict:
|
||||||
"reserved": key.reserved_balance,
|
return await get_balance_info(key, session)
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TopupRequest(BaseModel):
|
class TopupRequest(BaseModel):
|
||||||
@@ -85,6 +98,10 @@ async def topup_wallet_endpoint(
|
|||||||
key: ApiKey = Depends(get_key_from_header),
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> dict[str, int]:
|
) -> dict[str, int]:
|
||||||
|
from .auth import get_billing_key
|
||||||
|
|
||||||
|
billing_key = await get_billing_key(key, session)
|
||||||
|
|
||||||
if topup_request is not None:
|
if topup_request is not None:
|
||||||
cashu_token = topup_request.cashu_token
|
cashu_token = topup_request.cashu_token
|
||||||
if cashu_token is None:
|
if cashu_token is None:
|
||||||
@@ -94,7 +111,7 @@ async def topup_wallet_endpoint(
|
|||||||
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
if len(cashu_token) < 10 or "cashu" not in cashu_token:
|
||||||
raise HTTPException(status_code=400, detail="Invalid token format")
|
raise HTTPException(status_code=400, detail="Invalid token format")
|
||||||
try:
|
try:
|
||||||
amount_msats = await credit_balance(cashu_token, key, session)
|
amount_msats = await credit_balance(cashu_token, billing_key, session)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "already spent" in error_msg.lower():
|
if "already spent" in error_msg.lower():
|
||||||
@@ -155,6 +172,12 @@ async def refund_wallet_endpoint(
|
|||||||
|
|
||||||
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
key: ApiKey = await validate_bearer_key(bearer_value, session)
|
||||||
|
|
||||||
|
if key.parent_key_hash:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot refund child key. Please refund the parent key instead.",
|
||||||
|
)
|
||||||
|
|
||||||
remaining_balance_msats: int = key.total_balance
|
remaining_balance_msats: int = key.total_balance
|
||||||
|
|
||||||
if key.refund_currency == "sat":
|
if key.refund_currency == "sat":
|
||||||
@@ -228,6 +251,75 @@ async def donate(token: str, ref: str | None = None) -> str:
|
|||||||
return "Invalid token."
|
return "Invalid token."
|
||||||
|
|
||||||
|
|
||||||
|
class ChildKeyRequest(BaseModel):
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/child-key")
|
||||||
|
async def create_child_key(
|
||||||
|
payload: ChildKeyRequest,
|
||||||
|
key: ApiKey = Depends(get_key_from_header),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Creates one or more child API keys that use the parent's balance."""
|
||||||
|
# Log incoming request for debugging
|
||||||
|
logger.debug(f"Child key creation request: count={payload.count}")
|
||||||
|
|
||||||
|
count = payload.count
|
||||||
|
if count < 1 or count > 50:
|
||||||
|
raise HTTPException(status_code=400, detail="Count must be between 1 and 50.")
|
||||||
|
|
||||||
|
# Check if this is already a child key
|
||||||
|
if key.parent_key_hash:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot create a child key for another child key.",
|
||||||
|
)
|
||||||
|
|
||||||
|
cost_per_key = settings.child_key_cost
|
||||||
|
total_cost = cost_per_key * count
|
||||||
|
|
||||||
|
if key.total_balance < total_cost:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"Insufficient balance to create {count} child keys. {total_cost} mSats required.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deduct cost from parent
|
||||||
|
key.balance -= total_cost
|
||||||
|
key.total_spent += total_cost
|
||||||
|
session.add(key)
|
||||||
|
|
||||||
|
# Generate new keys
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
new_keys = []
|
||||||
|
for _ in range(count):
|
||||||
|
new_key_raw = secrets.token_hex(32)
|
||||||
|
new_key_hash = new_key_raw # We use the raw key as the hash for sk- keys
|
||||||
|
|
||||||
|
child_key = ApiKey(
|
||||||
|
hashed_key=new_key_hash,
|
||||||
|
balance=0,
|
||||||
|
parent_key_hash=key.hashed_key,
|
||||||
|
)
|
||||||
|
session.add(child_key)
|
||||||
|
new_keys.append("sk-" + new_key_hash)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
"api_keys": new_keys,
|
||||||
|
"count": count,
|
||||||
|
"cost_msats": total_cost,
|
||||||
|
"cost_sats": total_cost // 1000,
|
||||||
|
"parent_balance": key.balance,
|
||||||
|
"parent_balance_sats": key.balance // 1000,
|
||||||
|
}
|
||||||
|
logger.debug(f"Child key creation response: {response_data}")
|
||||||
|
return response_data
|
||||||
|
|
||||||
|
|
||||||
@router.api_route(
|
@router.api_route(
|
||||||
"/{path:path}",
|
"/{path:path}",
|
||||||
methods=["GET", "POST", "PUT", "DELETE"],
|
methods=["GET", "POST", "PUT", "DELETE"],
|
||||||
|
|||||||
+12
-9
@@ -124,7 +124,7 @@ async def partial_apikeys(request: Request) -> str:
|
|||||||
|
|
||||||
rows = "".join(
|
rows = "".join(
|
||||||
[
|
[
|
||||||
f"<tr><td>{key.hashed_key}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
f"<tr><td>{key.hashed_key}{' <br><small>(Child of ' + key.parent_key_hash[:8] + '...)</small>' if key.parent_key_hash else ''}</td><td>{key.balance}</td><td>{key.total_spent}</td><td>{key.total_requests}</td><td>{key.refund_address}</td><td>{fmt_time(key.key_expiry_time)}</td></tr>"
|
||||||
for key in api_keys
|
for key in api_keys
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -158,6 +158,7 @@ async def get_temporary_balances_api(request: Request) -> list[dict[str, object]
|
|||||||
"total_requests": key.total_requests,
|
"total_requests": key.total_requests,
|
||||||
"refund_address": key.refund_address,
|
"refund_address": key.refund_address,
|
||||||
"key_expiry_time": key.key_expiry_time,
|
"key_expiry_time": key.key_expiry_time,
|
||||||
|
"parent_key_hash": key.parent_key_hash,
|
||||||
}
|
}
|
||||||
for key in api_keys
|
for key in api_keys
|
||||||
]
|
]
|
||||||
@@ -2515,7 +2516,7 @@ async def get_provider_model(provider_id: int, model_id: str) -> dict[str, objec
|
|||||||
status_code=404, detail="Model not found for this provider"
|
status_code=404, detail="Model not found for this provider"
|
||||||
)
|
)
|
||||||
return _row_to_model(
|
return _row_to_model(
|
||||||
row, apply_provider_fee=True, provider_fee=provider.provider_fee
|
row, apply_provider_fee=False, provider_fee=provider.provider_fee
|
||||||
).dict() # type: ignore
|
).dict() # type: ignore
|
||||||
|
|
||||||
|
|
||||||
@@ -2597,12 +2598,14 @@ async def create_upstream_provider(
|
|||||||
async with create_session() as session:
|
async with create_session() as session:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == payload.base_url
|
UpstreamProviderRow.base_url == payload.base_url,
|
||||||
|
UpstreamProviderRow.api_key == payload.api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if result.first():
|
if result.first():
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409, detail="Provider with this base URL already exists"
|
status_code=409,
|
||||||
|
detail="Provider with this base URL and API key already exists",
|
||||||
)
|
)
|
||||||
|
|
||||||
provider = UpstreamProviderRow(
|
provider = UpstreamProviderRow(
|
||||||
@@ -2726,7 +2729,10 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
|||||||
raise HTTPException(status_code=404, detail="Provider not found")
|
raise HTTPException(status_code=404, detail="Provider not found")
|
||||||
|
|
||||||
db_models = await list_models(
|
db_models = await list_models(
|
||||||
session=session, upstream_id=provider_id, include_disabled=True
|
session=session,
|
||||||
|
upstream_id=provider_id,
|
||||||
|
include_disabled=True,
|
||||||
|
apply_fees=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
upstream_models = []
|
upstream_models = []
|
||||||
@@ -2734,10 +2740,7 @@ async def get_provider_models(provider_id: int) -> dict[str, object]:
|
|||||||
if upstream_instance:
|
if upstream_instance:
|
||||||
try:
|
try:
|
||||||
raw_models = await upstream_instance.fetch_models()
|
raw_models = await upstream_instance.fetch_models()
|
||||||
upstream_models = [
|
upstream_models = raw_models
|
||||||
upstream_instance._apply_provider_fee_to_model(m)
|
|
||||||
for m in raw_models
|
|
||||||
]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to fetch models from {provider.provider_type}: {e}"
|
f"Failed to fetch models from {provider.provider_type}: {e}"
|
||||||
|
|||||||
+8
-1
@@ -5,6 +5,7 @@ from typing import AsyncGenerator
|
|||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import UniqueConstraint
|
||||||
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
from sqlalchemy.ext.asyncio.engine import create_async_engine
|
||||||
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
from sqlmodel import Field, Relationship, SQLModel, func, select, update
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -47,6 +48,9 @@ class ApiKey(SQLModel, table=True): # type: ignore
|
|||||||
default=None,
|
default=None,
|
||||||
description="Currency of the cashu-token",
|
description="Currency of the cashu-token",
|
||||||
)
|
)
|
||||||
|
parent_key_hash: str | None = Field(
|
||||||
|
default=None, foreign_key="api_keys.hashed_key", index=True
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_balance(self) -> int:
|
def total_balance(self) -> int:
|
||||||
@@ -108,11 +112,14 @@ class LightningInvoice(SQLModel, table=True): # type: ignore
|
|||||||
|
|
||||||
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
class UpstreamProviderRow(SQLModel, table=True): # type: ignore
|
||||||
__tablename__ = "upstream_providers"
|
__tablename__ = "upstream_providers"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("base_url", "api_key", name="uq_upstream_providers_base_url_api_key"),
|
||||||
|
)
|
||||||
id: int | None = Field(default=None, primary_key=True)
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
provider_type: str = Field(
|
provider_type: str = Field(
|
||||||
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
description="Provider type: custom, openai, anthropic, azure, openrouter, etc."
|
||||||
)
|
)
|
||||||
base_url: str = Field(unique=True, description="Base URL of the upstream API")
|
base_url: str = Field(description="Base URL of the upstream API")
|
||||||
api_key: str = Field(description="API key for the upstream provider")
|
api_key: str = Field(description="API key for the upstream provider")
|
||||||
api_version: str | None = Field(
|
api_version: str | None = Field(
|
||||||
default=None, description="API version for Azure OpenAI"
|
default=None, description="API version for Azure OpenAI"
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ from .logging import get_logger
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamError(Exception):
|
||||||
|
"""Exception raised when an upstream provider fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: int = 502):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
async def http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
"""Handle HTTP exceptions and include request ID in response."""
|
"""Handle HTTP exceptions and include request ID in response."""
|
||||||
request_id = getattr(request.state, "request_id", "unknown")
|
request_id = getattr(request.state, "request_id", "unknown")
|
||||||
|
|||||||
@@ -13,10 +13,7 @@ from starlette.exceptions import HTTPException
|
|||||||
from ..balance import balance_router, deprecated_wallet_router
|
from ..balance import balance_router, deprecated_wallet_router
|
||||||
from ..discovery import providers_cache_refresher, providers_router
|
from ..discovery import providers_cache_refresher, providers_router
|
||||||
from ..nip91 import announce_provider
|
from ..nip91 import announce_provider
|
||||||
from ..payment.models import (
|
from ..payment.models import models_router, update_sats_pricing
|
||||||
models_router,
|
|
||||||
update_sats_pricing,
|
|
||||||
)
|
|
||||||
from ..payment.price import update_prices_periodically
|
from ..payment.price import update_prices_periodically
|
||||||
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
from ..proxy import initialize_upstreams, proxy_router, refresh_model_maps_periodically
|
||||||
from ..wallet import periodic_payout
|
from ..wallet import periodic_payout
|
||||||
@@ -33,9 +30,9 @@ setup_logging()
|
|||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
if os.getenv("VERSION_SUFFIX") is not None:
|
if os.getenv("VERSION_SUFFIX") is not None:
|
||||||
__version__ = f"0.2.2-{os.getenv('VERSION_SUFFIX')}"
|
__version__ = f"0.3.0-{os.getenv('VERSION_SUFFIX')}"
|
||||||
else:
|
else:
|
||||||
__version__ = "0.2.2"
|
__version__ = "0.3.0"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -66,6 +63,11 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
await reset_all_reserved_balances(session)
|
await reset_all_reserved_balances(session)
|
||||||
|
|
||||||
|
if not s.admin_password:
|
||||||
|
logger.warning(
|
||||||
|
f"Admin password is not set. Visit {s.http_url or 'http://localhost:8000'}/admin to set the password."
|
||||||
|
)
|
||||||
|
|
||||||
# Apply app metadata from settings
|
# Apply app metadata from settings
|
||||||
try:
|
try:
|
||||||
app.title = s.name
|
app.title = s.name
|
||||||
@@ -186,6 +188,7 @@ async def info() -> dict:
|
|||||||
"mints": global_settings.cashu_mints,
|
"mints": global_settings.cashu_mints,
|
||||||
"http_url": global_settings.http_url,
|
"http_url": global_settings.http_url,
|
||||||
"onion_url": global_settings.onion_url,
|
"onion_url": global_settings.onion_url,
|
||||||
|
"child_key_cost_msats": global_settings.child_key_cost,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic.v1 import BaseModel, BaseSettings, Field, validator
|
from pydantic.v1 import BaseModel, BaseSettings, Field
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
@@ -37,13 +37,6 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Cashu
|
# Cashu
|
||||||
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
|
cashu_mints: list[str] = Field(default_factory=list, env="CASHU_MINTS")
|
||||||
|
|
||||||
@validator("cashu_mints", pre=True, each_item=True)
|
|
||||||
def normalize_mint_url(cls, v: str) -> str:
|
|
||||||
if isinstance(v, str):
|
|
||||||
return v.rstrip("/")
|
|
||||||
return v
|
|
||||||
|
|
||||||
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
|
receive_ln_address: str = Field(default="", env="RECEIVE_LN_ADDRESS")
|
||||||
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
primary_mint: str = Field(default="", env="PRIMARY_MINT_URL")
|
||||||
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
primary_mint_unit: str = Field(default="sat", env="PRIMARY_MINT_UNIT")
|
||||||
@@ -59,6 +52,7 @@ class Settings(BaseSettings):
|
|||||||
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
exchange_fee: float = Field(default=1.005, env="EXCHANGE_FEE")
|
||||||
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
upstream_provider_fee: float = Field(default=1.05, env="UPSTREAM_PROVIDER_FEE")
|
||||||
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
tolerance_percentage: float = Field(default=1.0, env="TOLERANCE_PERCENTAGE")
|
||||||
|
child_key_cost: int = Field(default=1000, env="CHILD_KEY_COST")
|
||||||
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
# Minimum per-request charge in millisatoshis when model pricing is free/zero
|
||||||
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
min_request_msat: int = Field(default=1, env="MIN_REQUEST_MSAT")
|
||||||
reset_reserved_balance_on_startup: bool = Field(
|
reset_reserved_balance_on_startup: bool = Field(
|
||||||
@@ -69,7 +63,7 @@ class Settings(BaseSettings):
|
|||||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
cors_origins: list[str] = Field(default_factory=lambda: ["*"], env="CORS_ORIGINS")
|
||||||
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
|
tor_proxy_url: str = Field(default="socks5://127.0.0.1:9050", env="TOR_PROXY_URL")
|
||||||
providers_refresh_interval_seconds: int = Field(
|
providers_refresh_interval_seconds: int = Field(
|
||||||
default=300, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
default=0, env="PROVIDERS_REFRESH_INTERVAL_SECONDS"
|
||||||
)
|
)
|
||||||
pricing_refresh_interval_seconds: int = Field(
|
pricing_refresh_interval_seconds: int = Field(
|
||||||
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
|
default=120, env="PRICING_REFRESH_INTERVAL_SECONDS"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from .core.logging import get_logger
|
from .core.logging import get_logger
|
||||||
from .core.settings import settings
|
from .core.settings import settings
|
||||||
@@ -389,6 +389,9 @@ async def get_providers(
|
|||||||
Return cached providers. If include_json, return provider+health; otherwise provider only.
|
Return cached providers. If include_json, return provider+health; otherwise provider only.
|
||||||
Optional filter by pubkey.
|
Optional filter by pubkey.
|
||||||
"""
|
"""
|
||||||
|
if settings.providers_refresh_interval_seconds == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="Provider discovery is disabled")
|
||||||
|
|
||||||
cache = await get_cache()
|
cache = await get_cache()
|
||||||
if not cache:
|
if not cache:
|
||||||
await refresh_providers_cache(pubkey=pubkey)
|
await refresh_providers_cache(pubkey=pubkey)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class CostData(BaseModel):
|
|||||||
input_msats: int
|
input_msats: int
|
||||||
output_msats: int
|
output_msats: int
|
||||||
total_msats: int
|
total_msats: int
|
||||||
|
total_usd: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class MaxCostData(CostData):
|
class MaxCostData(CostData):
|
||||||
@@ -61,6 +62,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
input_msats=0,
|
input_msats=0,
|
||||||
output_msats=0,
|
output_msats=0,
|
||||||
total_msats=0,
|
total_msats=0,
|
||||||
|
total_usd=0.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
usage_data = response_data["usage"]
|
usage_data = response_data["usage"]
|
||||||
@@ -101,6 +103,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
input_msats=-1, # Cost field doesn't break down by token type
|
input_msats=-1, # Cost field doesn't break down by token type
|
||||||
output_msats=-1,
|
output_msats=-1,
|
||||||
total_msats=cost_in_msats,
|
total_msats=cost_in_msats,
|
||||||
|
total_usd=usd_cost,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -210,6 +213,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
|
|
||||||
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
output_msats = round(output_tokens / 1000 * MSATS_PER_1K_OUTPUT_TOKENS, 3)
|
||||||
token_based_cost = math.ceil(input_msats + output_msats)
|
token_based_cost = math.ceil(input_msats + output_msats)
|
||||||
|
total_usd = (token_based_cost / 1000.0) * sats_usd_price()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Calculated token-based cost",
|
"Calculated token-based cost",
|
||||||
@@ -219,6 +223,7 @@ async def calculate_cost( # todo: can be sync
|
|||||||
"input_cost_msats": input_msats,
|
"input_cost_msats": input_msats,
|
||||||
"output_cost_msats": output_msats,
|
"output_cost_msats": output_msats,
|
||||||
"total_cost_msats": token_based_cost,
|
"total_cost_msats": token_based_cost,
|
||||||
|
"total_usd": total_usd,
|
||||||
"model": response_data.get("model", "unknown"),
|
"model": response_data.get("model", "unknown"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -228,4 +233,5 @@ async def calculate_cost( # todo: can be sync
|
|||||||
input_msats=int(input_msats),
|
input_msats=int(input_msats),
|
||||||
output_msats=int(output_msats),
|
output_msats=int(output_msats),
|
||||||
total_msats=token_based_cost,
|
total_msats=token_based_cost,
|
||||||
|
total_usd=total_usd,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ async def list_models(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
upstream_id: int,
|
upstream_id: int,
|
||||||
include_disabled: bool = False,
|
include_disabled: bool = False,
|
||||||
|
apply_fees: bool = True,
|
||||||
) -> list[Model]:
|
) -> list[Model]:
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
@@ -247,12 +248,17 @@ async def list_models(
|
|||||||
return [
|
return [
|
||||||
_row_to_model(
|
_row_to_model(
|
||||||
r,
|
r,
|
||||||
apply_provider_fee=True,
|
apply_provider_fee=apply_fees,
|
||||||
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
provider_fee=providers_by_id[r.upstream_provider_id].provider_fee
|
||||||
if r.upstream_provider_id in providers_by_id
|
if r.upstream_provider_id in providers_by_id
|
||||||
else 1.01,
|
else 1.01,
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
|
if include_disabled
|
||||||
|
or (
|
||||||
|
r.upstream_provider_id in providers_by_id
|
||||||
|
and providers_by_id[r.upstream_provider_id].enabled
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -265,6 +271,8 @@ async def get_model_by_id(
|
|||||||
if not row or not row.enabled:
|
if not row or not row.enabled:
|
||||||
return None
|
return None
|
||||||
provider = await session.get(UpstreamProviderRow, provider_id)
|
provider = await session.get(UpstreamProviderRow, provider_id)
|
||||||
|
if not provider or not provider.enabled:
|
||||||
|
return None
|
||||||
provider_fee = provider.provider_fee if provider else 1.01
|
provider_fee = provider.provider_fee if provider else 1.01
|
||||||
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
return _row_to_model(row, apply_provider_fee=True, provider_fee=provider_fee)
|
||||||
|
|
||||||
|
|||||||
@@ -79,15 +79,29 @@ async def _fetch_btc_usd_price() -> float:
|
|||||||
"""Fetch the lowest BTC/USD price from multiple exchanges."""
|
"""Fetch the lowest BTC/USD price from multiple exchanges."""
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
try:
|
try:
|
||||||
prices = await asyncio.gather(
|
tasks = [
|
||||||
_kraken_btc_usd(client),
|
asyncio.create_task(_kraken_btc_usd(client)),
|
||||||
_coinbase_btc_usd(client),
|
asyncio.create_task(_coinbase_btc_usd(client)),
|
||||||
_binance_btc_usdt(client),
|
asyncio.create_task(_binance_btc_usdt(client)),
|
||||||
)
|
]
|
||||||
valid_prices = [price for price in prices if price is not None]
|
valid_prices: list[float] = []
|
||||||
|
|
||||||
|
for future in asyncio.as_completed(tasks):
|
||||||
|
price = await future
|
||||||
|
if price is not None:
|
||||||
|
valid_prices.append(price)
|
||||||
|
|
||||||
|
if len(valid_prices) >= 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
if not valid_prices:
|
if not valid_prices:
|
||||||
logger.error("No valid BTC prices obtained from any exchange")
|
logger.error("No valid BTC prices obtained from any exchange")
|
||||||
raise ValueError("Unable to fetch BTC price from any exchange")
|
raise ValueError("Unable to fetch BTC price from any exchange")
|
||||||
|
|
||||||
return min(valid_prices)
|
return min(valid_prices)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|||||||
+178
-76
@@ -16,7 +16,7 @@ from .core.db import (
|
|||||||
create_session,
|
create_session,
|
||||||
get_session,
|
get_session,
|
||||||
)
|
)
|
||||||
from .core.settings import settings
|
from .core.exceptions import UpstreamError
|
||||||
from .payment.helpers import (
|
from .payment.helpers import (
|
||||||
calculate_discounted_max_cost,
|
calculate_discounted_max_cost,
|
||||||
check_token_balance,
|
check_token_balance,
|
||||||
@@ -26,14 +26,15 @@ from .payment.helpers import (
|
|||||||
from .payment.models import Model
|
from .payment.models import Model
|
||||||
from .upstream import BaseUpstreamProvider
|
from .upstream import BaseUpstreamProvider
|
||||||
from .upstream.helpers import init_upstreams
|
from .upstream.helpers import init_upstreams
|
||||||
from .wallet import deserialize_token_from_string
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
proxy_router = APIRouter()
|
proxy_router = APIRouter()
|
||||||
|
|
||||||
_upstreams: list[BaseUpstreamProvider] = []
|
_upstreams: list[BaseUpstreamProvider] = []
|
||||||
_model_instances: dict[str, Model] = {} # All aliases -> Model
|
_model_instances: dict[str, Model] = {} # All aliases -> Model
|
||||||
_provider_map: dict[str, BaseUpstreamProvider] = {} # All aliases -> Provider
|
_provider_map: dict[
|
||||||
|
str, list[BaseUpstreamProvider]
|
||||||
|
] = {} # All aliases -> List[Provider]
|
||||||
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
|
_unique_models: dict[str, Model] = {} # Unique model.id -> Model (no duplicates)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,8 +71,8 @@ def get_model_instance(model_id: str) -> Model | None:
|
|||||||
return _model_instances.get(model_id.lower())
|
return _model_instances.get(model_id.lower())
|
||||||
|
|
||||||
|
|
||||||
def get_provider_for_model(model_id: str) -> BaseUpstreamProvider | None:
|
def get_provider_for_model(model_id: str) -> list[BaseUpstreamProvider] | None:
|
||||||
"""Get UpstreamProvider for model ID from global cache."""
|
"""Get UpstreamProvider list for model ID from global cache."""
|
||||||
return _provider_map.get(model_id.lower())
|
return _provider_map.get(model_id.lower())
|
||||||
|
|
||||||
|
|
||||||
@@ -98,6 +99,8 @@ async def refresh_model_maps() -> None:
|
|||||||
disabled_model_ids: set[str] = set()
|
disabled_model_ids: set[str] = set()
|
||||||
|
|
||||||
for provider in provider_rows:
|
for provider in provider_rows:
|
||||||
|
if not provider.enabled:
|
||||||
|
continue
|
||||||
for model in provider.models:
|
for model in provider.models:
|
||||||
if model.enabled:
|
if model.enabled:
|
||||||
overrides_by_id[model.id] = (model, provider.provider_fee)
|
overrides_by_id[model.id] = (model, provider.provider_fee)
|
||||||
@@ -148,32 +151,14 @@ async def proxy(
|
|||||||
else:
|
else:
|
||||||
model_id = request_body_dict.get("model", "unknown")
|
model_id = request_body_dict.get("model", "unknown")
|
||||||
|
|
||||||
if "https://testnut.cashu.space" in settings.cashu_mints:
|
|
||||||
try:
|
|
||||||
token_str = None
|
|
||||||
if x_cashu_header := headers.get("x-cashu"):
|
|
||||||
token_str = x_cashu_header
|
|
||||||
elif auth_header := headers.get("authorization"):
|
|
||||||
parts = auth_header.split(" ")
|
|
||||||
if len(parts) > 1 and not parts[1].startswith("sk-"):
|
|
||||||
token_str = parts[1]
|
|
||||||
|
|
||||||
if token_str:
|
|
||||||
token_obj = deserialize_token_from_string(token_str)
|
|
||||||
if token_obj.mint == "https://testnut.cashu.space":
|
|
||||||
model_id = "mock/gpt-420-mock"
|
|
||||||
request_body_dict["model"] = model_id
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
model_obj = get_model_instance(model_id)
|
model_obj = get_model_instance(model_id)
|
||||||
if not model_obj:
|
if not model_obj:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
"invalid_model", f"Model '{model_id}' not found", 400, request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
upstream = get_provider_for_model(model_id)
|
upstreams = get_provider_for_model(model_id)
|
||||||
if not upstream:
|
if not upstreams:
|
||||||
return create_error_response(
|
return create_error_response(
|
||||||
"invalid_model",
|
"invalid_model",
|
||||||
f"No provider found for model '{model_id}'",
|
f"No provider found for model '{model_id}'",
|
||||||
@@ -181,6 +166,10 @@ async def proxy(
|
|||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# todo figure out cost calculation since fallback provider is usually not the same price
|
||||||
|
# Use first provider for initial checks/cost calculation
|
||||||
|
# primary_upstream = upstreams[0]
|
||||||
|
|
||||||
_max_cost_for_model = await get_max_cost_for_model(
|
_max_cost_for_model = await get_max_cost_for_model(
|
||||||
model=model_id, session=session, model_obj=model_obj
|
model=model_id, session=session, model_obj=model_obj
|
||||||
)
|
)
|
||||||
@@ -190,14 +179,31 @@ async def proxy(
|
|||||||
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
check_token_balance(headers, request_body_dict, max_cost_for_model)
|
||||||
|
|
||||||
if x_cashu := headers.get("x-cashu", None):
|
if x_cashu := headers.get("x-cashu", None):
|
||||||
if is_responses_api:
|
last_error = None
|
||||||
return await upstream.handle_x_cashu_responses(
|
for i, upstream in enumerate(upstreams):
|
||||||
request, x_cashu, path, max_cost_for_model, model_obj
|
try:
|
||||||
)
|
if is_responses_api:
|
||||||
else:
|
return await upstream.handle_x_cashu_responses(
|
||||||
return await upstream.handle_x_cashu(
|
request, x_cashu, path, max_cost_for_model, model_obj
|
||||||
request, x_cashu, path, max_cost_for_model, model_obj
|
)
|
||||||
)
|
else:
|
||||||
|
return await upstream.handle_x_cashu(
|
||||||
|
request, x_cashu, path, max_cost_for_model, model_obj
|
||||||
|
)
|
||||||
|
except UpstreamError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} failed (x-cashu): {e}"
|
||||||
|
)
|
||||||
|
if i == len(upstreams) - 1:
|
||||||
|
last_error = e
|
||||||
|
continue
|
||||||
|
|
||||||
|
return create_error_response(
|
||||||
|
"upstream_error",
|
||||||
|
str(last_error) if last_error else "All upstreams failed",
|
||||||
|
502,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
elif auth := headers.get("authorization", None):
|
elif auth := headers.get("authorization", None):
|
||||||
key = await get_bearer_token_key(headers, path, session, auth)
|
key = await get_bearer_token_key(headers, path, session, auth)
|
||||||
@@ -212,56 +218,152 @@ async def proxy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
logger.debug("Processing unauthenticated GET request", extra={"path": path})
|
||||||
headers = upstream.prepare_headers(dict(request.headers))
|
|
||||||
return await upstream.forward_get_request(request, path, headers)
|
last_error_response = None
|
||||||
|
for i, upstream in enumerate(upstreams):
|
||||||
|
try:
|
||||||
|
headers = upstream.prepare_headers(dict(request.headers))
|
||||||
|
response = await upstream.forward_get_request(request, path, headers)
|
||||||
|
|
||||||
|
if response.status_code in [502, 429] and i < len(upstreams) - 1:
|
||||||
|
error_message = ""
|
||||||
|
try:
|
||||||
|
if hasattr(response, "body"):
|
||||||
|
body_bytes = response.body
|
||||||
|
data = json.loads(body_bytes)
|
||||||
|
if "error" in data:
|
||||||
|
error_data = data["error"]
|
||||||
|
if isinstance(error_data, dict):
|
||||||
|
error_message = error_data.get("message", "")
|
||||||
|
elif isinstance(error_data, str):
|
||||||
|
error_message = error_data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await upstream.on_upstream_error_redirect(
|
||||||
|
response.status_code, error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} returned {response.status_code} (GET), trying next provider",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"upstream": upstream.provider_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return response
|
||||||
|
except UpstreamError as e:
|
||||||
|
logger.warning(f"Upstream {upstream.provider_type} failed (GET): {e}")
|
||||||
|
if i == len(upstreams) - 1:
|
||||||
|
last_error_response = create_error_response(
|
||||||
|
"upstream_error", str(e), 502, request=request
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
return last_error_response or create_error_response(
|
||||||
|
"upstream_error", "All upstreams failed", 502, request=request
|
||||||
|
)
|
||||||
|
|
||||||
if request_body_dict:
|
if request_body_dict:
|
||||||
await pay_for_request(key, max_cost_for_model, session)
|
await pay_for_request(key, max_cost_for_model, session)
|
||||||
|
|
||||||
headers = upstream.prepare_headers(dict(request.headers))
|
for i, upstream in enumerate(upstreams):
|
||||||
|
headers = upstream.prepare_headers(dict(request.headers))
|
||||||
|
|
||||||
if is_responses_api:
|
try:
|
||||||
response = await upstream.forward_responses_request(
|
if is_responses_api:
|
||||||
request,
|
response = await upstream.forward_responses_request(
|
||||||
path,
|
request,
|
||||||
headers,
|
path,
|
||||||
request_body,
|
headers,
|
||||||
key,
|
request_body,
|
||||||
max_cost_for_model,
|
key,
|
||||||
session,
|
max_cost_for_model,
|
||||||
model_obj,
|
session,
|
||||||
)
|
model_obj,
|
||||||
else:
|
)
|
||||||
response = await upstream.forward_request(
|
else:
|
||||||
request,
|
response = await upstream.forward_request(
|
||||||
path,
|
request,
|
||||||
headers,
|
path,
|
||||||
request_body,
|
headers,
|
||||||
key,
|
request_body,
|
||||||
max_cost_for_model,
|
key,
|
||||||
session,
|
max_cost_for_model,
|
||||||
model_obj,
|
session,
|
||||||
)
|
model_obj,
|
||||||
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
await revert_pay_for_request(key, session, max_cost_for_model)
|
# Check if we should retry (502 Upstream Error or 429 Rate Limit)
|
||||||
logger.warning(
|
should_retry = response.status_code in [502, 429, 400, 401, 403, 404]
|
||||||
"Upstream request failed, revert payment",
|
if should_retry and i < len(upstreams) - 1:
|
||||||
extra={
|
error_message = ""
|
||||||
"status_code": response.status_code,
|
try:
|
||||||
"path": path,
|
if hasattr(response, "body"):
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
body_bytes = response.body
|
||||||
"key_balance": key.balance,
|
data = json.loads(body_bytes)
|
||||||
"max_cost_for_model": max_cost_for_model,
|
if "error" in data:
|
||||||
"upstream_headers": response.headers
|
error_data = data["error"]
|
||||||
if hasattr(response, "headers")
|
if isinstance(error_data, dict):
|
||||||
else None,
|
error_message = error_data.get("message", "")
|
||||||
},
|
elif isinstance(error_data, str):
|
||||||
)
|
error_message = error_data
|
||||||
# Return the mapped error response generated earlier rather than masking with 502
|
except Exception:
|
||||||
return response
|
pass
|
||||||
|
|
||||||
return response
|
await upstream.on_upstream_error_redirect(
|
||||||
|
response.status_code, error_message
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} returned {response.status_code}, trying next provider",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"upstream": upstream.provider_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4xx error (user error), or other non-retryable error, or last provider failed
|
||||||
|
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||||
|
logger.warning(
|
||||||
|
"Upstream request failed, revert payment",
|
||||||
|
extra={
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"path": path,
|
||||||
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
|
"key_balance": key.balance,
|
||||||
|
"max_cost_for_model": max_cost_for_model,
|
||||||
|
"upstream_headers": response.headers
|
||||||
|
if hasattr(response, "headers")
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except UpstreamError as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Upstream {upstream.provider_type} failed: {e}",
|
||||||
|
extra={"retry": i < len(upstreams) - 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If this was the last provider
|
||||||
|
if i == len(upstreams) - 1:
|
||||||
|
await revert_pay_for_request(key, session, max_cost_for_model)
|
||||||
|
return create_error_response(
|
||||||
|
"upstream_error", str(e), 502, request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
# Otherwise loop continues to next provider
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Should not be reached given logic above
|
||||||
|
return create_error_response(
|
||||||
|
"upstream_error", "All upstreams failed", 502, request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_bearer_token_key(
|
async def get_bearer_token_key(
|
||||||
|
|||||||
+230
-488
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
|||||||
from ..auth import adjust_payment_for_tokens
|
from ..auth import adjust_payment_for_tokens
|
||||||
from ..core import get_logger
|
from ..core import get_logger
|
||||||
from ..core.db import ApiKey, AsyncSession, create_session
|
from ..core.db import ApiKey, AsyncSession, create_session
|
||||||
|
from ..core.exceptions import UpstreamError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..core.db import UpstreamProviderRow
|
from ..core.db import UpstreamProviderRow
|
||||||
@@ -340,6 +341,20 @@ class BaseUpstreamProvider:
|
|||||||
message = preview[:500]
|
message = preview[:500]
|
||||||
return message, upstream_code
|
return message, upstream_code
|
||||||
|
|
||||||
|
async def on_upstream_error_redirect(
|
||||||
|
self, status_code: int, error_message: str
|
||||||
|
) -> None:
|
||||||
|
"""Hook called when the proxy redirects to another provider due to an error.
|
||||||
|
|
||||||
|
Subclasses can implement this to perform actions like disabling the provider
|
||||||
|
if it's out of balance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_code: The HTTP status code returned by the upstream
|
||||||
|
error_message: The error message extracted from the upstream response
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
async def map_upstream_error_response(
|
async def map_upstream_error_response(
|
||||||
self, request: Request, path: str, upstream_response: httpx.Response
|
self, request: Request, path: str, upstream_response: httpx.Response
|
||||||
) -> Response:
|
) -> Response:
|
||||||
@@ -436,164 +451,111 @@ class BaseUpstreamProvider:
|
|||||||
async def stream_with_cost(
|
async def stream_with_cost(
|
||||||
max_cost_for_model: int,
|
max_cost_for_model: int,
|
||||||
) -> AsyncGenerator[bytes, None]:
|
) -> AsyncGenerator[bytes, None]:
|
||||||
stored_chunks: list[bytes] = []
|
|
||||||
usage_finalized: bool = False
|
usage_finalized: bool = False
|
||||||
last_model_seen: str | None = None
|
last_model_seen: str | None = None
|
||||||
|
usage_chunk_data: dict | None = None
|
||||||
|
done_seen: bool = False
|
||||||
|
|
||||||
async def finalize_without_usage() -> bytes | None:
|
async def finalize_db_only() -> None:
|
||||||
nonlocal usage_finalized
|
nonlocal usage_finalized
|
||||||
if usage_finalized:
|
if usage_finalized:
|
||||||
return None
|
return
|
||||||
async with create_session() as new_session:
|
async with create_session() as new_session:
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||||
if not fresh_key:
|
if not fresh_key:
|
||||||
logger.warning(
|
return
|
||||||
"Key not found when finalizing streaming payment",
|
|
||||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
fallback: dict = {
|
await adjust_payment_for_tokens(
|
||||||
"model": last_model_seen or "unknown",
|
fresh_key,
|
||||||
"usage": None,
|
{"model": last_model_seen or "unknown", "usage": None},
|
||||||
}
|
new_session,
|
||||||
cost_data = await adjust_payment_for_tokens(
|
max_cost_for_model,
|
||||||
fresh_key, fallback, new_session, max_cost_for_model
|
|
||||||
)
|
)
|
||||||
usage_finalized = True
|
usage_finalized = True
|
||||||
logger.info(
|
|
||||||
"Finalized streaming payment without explicit usage",
|
|
||||||
extra={
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error finalizing payment without usage",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(cost_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for chunk in response.aiter_bytes():
|
|
||||||
stored_chunks.append(chunk)
|
|
||||||
try:
|
|
||||||
for part in re.split(b"data: ", chunk):
|
|
||||||
if not part or part.strip() in (b"[DONE]", b""):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = json.loads(part)
|
|
||||||
if isinstance(obj, dict) and obj.get("model"):
|
|
||||||
last_model_seen = str(obj.get("model"))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
yield chunk
|
try:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
# Split chunk into SSE events
|
||||||
|
parts = re.split(b"data: ", chunk)
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.debug(
|
stripped_part = part.strip()
|
||||||
"Streaming completed, analyzing usage data",
|
if not stripped_part:
|
||||||
extra={
|
continue
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"chunks_count": len(stored_chunks),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
for i in range(len(stored_chunks) - 1, -1, -1):
|
if stripped_part == b"[DONE]":
|
||||||
chunk = stored_chunks[i]
|
done_seen = True
|
||||||
if not chunk:
|
continue
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
events = re.split(b"data: ", chunk)
|
obj = json.loads(part)
|
||||||
for event_data in events:
|
if isinstance(obj, dict):
|
||||||
if not event_data or event_data.strip() in (b"[DONE]", b""):
|
if obj.get("model"):
|
||||||
continue
|
last_model_seen = str(obj.get("model"))
|
||||||
try:
|
|
||||||
data = json.loads(event_data)
|
if isinstance(obj.get("usage"), dict):
|
||||||
if isinstance(data, dict) and data.get("model"):
|
# Hold this chunk back to merge cost later
|
||||||
last_model_seen = str(data.get("model"))
|
usage_chunk_data = obj
|
||||||
if isinstance(data, dict) and isinstance(
|
continue
|
||||||
data.get("usage"), dict
|
except json.JSONDecodeError:
|
||||||
):
|
pass
|
||||||
async with create_session() as new_session:
|
|
||||||
fresh_key = await new_session.get(
|
prefix = (
|
||||||
key.__class__, key.hashed_key
|
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
|
||||||
)
|
|
||||||
if fresh_key:
|
|
||||||
try:
|
|
||||||
cost_data = (
|
|
||||||
await adjust_payment_for_tokens(
|
|
||||||
fresh_key,
|
|
||||||
data,
|
|
||||||
new_session,
|
|
||||||
max_cost_for_model,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
logger.info(
|
|
||||||
"Payment adjustment completed for streaming",
|
|
||||||
extra={
|
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"model": last_model_seen,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
yield f"data: {json.dumps({'cost': cost_data})}\n\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error adjusting payment for streaming tokens",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(
|
|
||||||
cost_error
|
|
||||||
).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Error processing streaming response chunk",
|
|
||||||
extra={
|
|
||||||
"error": str(e),
|
|
||||||
"error_type": type(e).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
yield prefix + part
|
||||||
|
|
||||||
|
# Stream finished, process usage if found
|
||||||
|
if usage_chunk_data:
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(key.__class__, key.hashed_key)
|
||||||
|
if fresh_key:
|
||||||
|
try:
|
||||||
|
cost_data = await adjust_payment_for_tokens(
|
||||||
|
fresh_key,
|
||||||
|
usage_chunk_data,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
|
)
|
||||||
|
# Merge cost into usage
|
||||||
|
usage_chunk_data["usage"]["cost"] = cost_data.get(
|
||||||
|
"total_usd", 0.0
|
||||||
|
)
|
||||||
|
# Keep detailed cost in metadata
|
||||||
|
usage_chunk_data["metadata"] = usage_chunk_data.get(
|
||||||
|
"metadata", {}
|
||||||
|
)
|
||||||
|
usage_chunk_data["metadata"]["routstr"] = {
|
||||||
|
"cost": cost_data
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||||
|
usage_finalized = True
|
||||||
|
except Exception:
|
||||||
|
# Fallback: yield original usage chunk if adjustment fails
|
||||||
|
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||||
|
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
maybe_cost_event = await finalize_without_usage()
|
await finalize_db_only()
|
||||||
if maybe_cost_event is not None:
|
|
||||||
yield maybe_cost_event
|
if done_seen:
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
except Exception as stream_error:
|
except Exception as stream_error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Streaming interrupted; finalizing without usage",
|
"Streaming interrupted; finalizing in background",
|
||||||
extra={
|
extra={
|
||||||
"error": str(stream_error),
|
"error": str(stream_error),
|
||||||
"error_type": type(stream_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
await finalize_without_usage()
|
await finalize_db_only()
|
||||||
|
|
||||||
# Remove inaccurate encoding headers from upstream response
|
# Remove inaccurate encoding headers from upstream response
|
||||||
response_headers = dict(response.headers)
|
response_headers = dict(response.headers)
|
||||||
@@ -633,6 +595,7 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
content: bytes | None = None
|
||||||
try:
|
try:
|
||||||
content = await response.aread()
|
content = await response.aread()
|
||||||
response_json = json.loads(content)
|
response_json = json.loads(content)
|
||||||
@@ -649,6 +612,14 @@ class BaseUpstreamProvider:
|
|||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Merge cost into usage for OpenCode
|
||||||
|
if "usage" in response_json:
|
||||||
|
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
|
||||||
|
|
||||||
|
# Keep detailed cost
|
||||||
|
response_json["metadata"] = response_json.get("metadata", {})
|
||||||
|
response_json["metadata"]["routstr"] = {"cost": cost_data}
|
||||||
response_json["cost"] = cost_data
|
response_json["cost"] = cost_data
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -734,180 +705,135 @@ class BaseUpstreamProvider:
|
|||||||
async def stream_with_responses_cost(
|
async def stream_with_responses_cost(
|
||||||
max_cost_for_model: int,
|
max_cost_for_model: int,
|
||||||
) -> AsyncGenerator[bytes, None]:
|
) -> AsyncGenerator[bytes, None]:
|
||||||
stored_chunks: list[bytes] = []
|
|
||||||
usage_finalized: bool = False
|
usage_finalized: bool = False
|
||||||
last_model_seen: str | None = None
|
last_model_seen: str | None = None
|
||||||
reasoning_tokens: int = 0
|
reasoning_tokens: int = 0
|
||||||
|
usage_chunk_data: dict | None = None
|
||||||
|
done_seen: bool = False
|
||||||
|
|
||||||
async def finalize_without_usage() -> bytes | None:
|
async def finalize_db_only() -> None:
|
||||||
nonlocal usage_finalized
|
nonlocal usage_finalized
|
||||||
if usage_finalized:
|
if usage_finalized:
|
||||||
return None
|
return
|
||||||
async with create_session() as new_session:
|
async with create_session() as new_session:
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
||||||
if not fresh_key:
|
if not fresh_key:
|
||||||
logger.warning(
|
return
|
||||||
"Key not found when finalizing Responses API streaming payment",
|
|
||||||
extra={"key_hash": key.hashed_key[:8] + "..."},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
fallback: dict = {
|
await adjust_payment_for_tokens(
|
||||||
"model": last_model_seen or "unknown",
|
fresh_key,
|
||||||
"usage": None,
|
{"model": last_model_seen or "unknown", "usage": None},
|
||||||
}
|
new_session,
|
||||||
cost_data = await adjust_payment_for_tokens(
|
max_cost_for_model,
|
||||||
fresh_key, fallback, new_session, max_cost_for_model
|
|
||||||
)
|
)
|
||||||
usage_finalized = True
|
usage_finalized = True
|
||||||
logger.info(
|
|
||||||
"Finalized Responses API streaming payment without explicit usage",
|
|
||||||
extra={
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error finalizing Responses API payment without usage",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(cost_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for chunk in response.aiter_bytes():
|
|
||||||
stored_chunks.append(chunk)
|
|
||||||
try:
|
|
||||||
for part in re.split(b"data: ", chunk):
|
|
||||||
if not part or part.strip() in (b"[DONE]", b""):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
obj = json.loads(part)
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
if obj.get("model"):
|
|
||||||
last_model_seen = str(obj.get("model"))
|
|
||||||
|
|
||||||
# Track reasoning tokens for Responses API
|
|
||||||
if usage := obj.get("usage", {}):
|
|
||||||
if (
|
|
||||||
isinstance(usage, dict)
|
|
||||||
and "reasoning_tokens" in usage
|
|
||||||
):
|
|
||||||
reasoning_tokens += usage.get(
|
|
||||||
"reasoning_tokens", 0
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
yield chunk
|
try:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
# Split chunk into SSE events
|
||||||
|
parts = re.split(b"data: ", chunk)
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
|
||||||
logger.debug(
|
stripped_part = part.strip()
|
||||||
"Responses API streaming completed, analyzing usage data",
|
if not stripped_part:
|
||||||
extra={
|
continue
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
"chunks_count": len(stored_chunks),
|
|
||||||
"reasoning_tokens": reasoning_tokens,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process final usage data
|
if stripped_part == b"[DONE]":
|
||||||
for i in range(len(stored_chunks) - 1, -1, -1):
|
done_seen = True
|
||||||
chunk = stored_chunks[i]
|
continue
|
||||||
if not chunk:
|
|
||||||
continue
|
try:
|
||||||
try:
|
obj = json.loads(part)
|
||||||
events = re.split(b"data: ", chunk)
|
if isinstance(obj, dict):
|
||||||
for event_data in events:
|
if obj.get("model"):
|
||||||
if not event_data or event_data.strip() in (b"[DONE]", b""):
|
last_model_seen = str(obj.get("model"))
|
||||||
continue
|
|
||||||
try:
|
# Track reasoning tokens for Responses API
|
||||||
data = json.loads(event_data)
|
if usage := obj.get("usage", {}):
|
||||||
if isinstance(data, dict) and data.get("model"):
|
if (
|
||||||
last_model_seen = str(data.get("model"))
|
isinstance(usage, dict)
|
||||||
if isinstance(data, dict) and isinstance(
|
and "reasoning_tokens" in usage
|
||||||
data.get("usage"), dict
|
):
|
||||||
):
|
reasoning_tokens += usage.get(
|
||||||
# Include reasoning tokens in usage calculation
|
"reasoning_tokens", 0
|
||||||
async with create_session() as new_session:
|
|
||||||
fresh_key = await new_session.get(
|
|
||||||
key.__class__, key.hashed_key
|
|
||||||
)
|
)
|
||||||
if fresh_key:
|
|
||||||
try:
|
# Responses API usage is in response.completed/incomplete events
|
||||||
cost_data = (
|
chunk_type = obj.get("type", "")
|
||||||
await adjust_payment_for_tokens(
|
if chunk_type in (
|
||||||
fresh_key,
|
"response.completed",
|
||||||
data,
|
"response.incomplete",
|
||||||
new_session,
|
):
|
||||||
max_cost_for_model,
|
usage_chunk_data = obj
|
||||||
)
|
continue
|
||||||
)
|
except json.JSONDecodeError:
|
||||||
usage_finalized = True
|
pass
|
||||||
logger.info(
|
|
||||||
"Payment adjustment completed for Responses API streaming",
|
prefix = (
|
||||||
extra={
|
b"data: " if (i > 0 or chunk.startswith(b"data: ")) else b""
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
"cost_data": cost_data,
|
|
||||||
"model": last_model_seen,
|
|
||||||
"reasoning_tokens": reasoning_tokens,
|
|
||||||
"balance_after_adjustment": fresh_key.balance,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
yield f"data: {json.dumps({'cost': cost_data})}\\n\\n".encode()
|
|
||||||
except Exception as cost_error:
|
|
||||||
logger.error(
|
|
||||||
"Error adjusting payment for Responses API streaming tokens",
|
|
||||||
extra={
|
|
||||||
"error": str(cost_error),
|
|
||||||
"error_type": type(
|
|
||||||
cost_error
|
|
||||||
).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8]
|
|
||||||
+ "...",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
break
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
"Error processing Responses API streaming response chunk",
|
|
||||||
extra={
|
|
||||||
"error": str(e),
|
|
||||||
"error_type": type(e).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
yield prefix + part
|
||||||
|
|
||||||
|
# Stream finished, process usage if found
|
||||||
|
if usage_chunk_data:
|
||||||
|
async with create_session() as session:
|
||||||
|
fresh_key = await session.get(key.__class__, key.hashed_key)
|
||||||
|
if fresh_key:
|
||||||
|
try:
|
||||||
|
cost_data = await adjust_payment_for_tokens(
|
||||||
|
fresh_key,
|
||||||
|
usage_chunk_data,
|
||||||
|
session,
|
||||||
|
max_cost_for_model,
|
||||||
|
)
|
||||||
|
# Merge cost into usage chunk
|
||||||
|
if (
|
||||||
|
"response" in usage_chunk_data
|
||||||
|
and "usage" in usage_chunk_data["response"]
|
||||||
|
):
|
||||||
|
usage_chunk_data["response"]["usage"]["cost"] = (
|
||||||
|
cost_data.get("total_usd", 0.0)
|
||||||
|
)
|
||||||
|
elif "usage" in usage_chunk_data:
|
||||||
|
usage_chunk_data["usage"]["cost"] = cost_data.get(
|
||||||
|
"total_usd", 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep detailed cost in metadata
|
||||||
|
usage_chunk_data["metadata"] = usage_chunk_data.get(
|
||||||
|
"metadata", {}
|
||||||
|
)
|
||||||
|
usage_chunk_data["metadata"]["routstr"] = {
|
||||||
|
"cost": cost_data
|
||||||
|
}
|
||||||
|
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||||
|
usage_finalized = True
|
||||||
|
except Exception:
|
||||||
|
# Fallback: yield original usage chunk if adjustment fails
|
||||||
|
yield f"data: {json.dumps(usage_chunk_data)}\n\n".encode()
|
||||||
|
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
maybe_cost_event = await finalize_without_usage()
|
await finalize_db_only()
|
||||||
if maybe_cost_event is not None:
|
|
||||||
yield maybe_cost_event
|
if done_seen:
|
||||||
|
yield b"data: [DONE]\n\n"
|
||||||
|
|
||||||
except Exception as stream_error:
|
except Exception as stream_error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Responses API streaming interrupted; finalizing without usage",
|
"Responses API streaming interrupted; finalizing in background",
|
||||||
extra={
|
extra={
|
||||||
"error": str(stream_error),
|
"error": str(stream_error),
|
||||||
"error_type": type(stream_error).__name__,
|
|
||||||
"key_hash": key.hashed_key[:8] + "...",
|
"key_hash": key.hashed_key[:8] + "...",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if not usage_finalized:
|
if not usage_finalized:
|
||||||
await finalize_without_usage()
|
await finalize_db_only()
|
||||||
|
|
||||||
# Remove inaccurate encoding headers from upstream response
|
# Remove inaccurate encoding headers from upstream response
|
||||||
response_headers = dict(response.headers)
|
response_headers = dict(response.headers)
|
||||||
@@ -947,6 +873,7 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
content: bytes | None = None
|
||||||
try:
|
try:
|
||||||
content = await response.aread()
|
content = await response.aread()
|
||||||
response_json = json.loads(content)
|
response_json = json.loads(content)
|
||||||
@@ -966,6 +893,14 @@ class BaseUpstreamProvider:
|
|||||||
cost_data = await adjust_payment_for_tokens(
|
cost_data = await adjust_payment_for_tokens(
|
||||||
key, response_json, session, deducted_max_cost
|
key, response_json, session, deducted_max_cost
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Merge cost into usage for OpenCode
|
||||||
|
if "usage" in response_json:
|
||||||
|
response_json["usage"]["cost"] = cost_data.get("total_usd", 0.0)
|
||||||
|
|
||||||
|
# Keep detailed cost
|
||||||
|
response_json["metadata"] = response_json.get("metadata", {})
|
||||||
|
response_json["metadata"]["routstr"] = {"cost": cost_data}
|
||||||
response_json["cost"] = cost_data
|
response_json["cost"] = cost_data
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1064,170 +999,6 @@ class BaseUpstreamProvider:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_streaming_messages_completion(
|
|
||||||
self, response: httpx.Response, key: ApiKey, max_cost_for_model: int
|
|
||||||
) -> StreamingResponse:
|
|
||||||
async def stream_with_cost(
|
|
||||||
max_cost_for_model: int,
|
|
||||||
) -> AsyncGenerator[bytes, None]:
|
|
||||||
stored_chunks: list[bytes] = []
|
|
||||||
usage_finalized: bool = False
|
|
||||||
last_model_seen: str | None = None
|
|
||||||
input_tokens: int = 0
|
|
||||||
output_tokens: int = 0
|
|
||||||
|
|
||||||
async def finalize_without_usage() -> bytes | None:
|
|
||||||
nonlocal usage_finalized
|
|
||||||
if usage_finalized:
|
|
||||||
return None
|
|
||||||
async with create_session() as new_session:
|
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
|
||||||
if not fresh_key:
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
fallback: dict = {
|
|
||||||
"model": last_model_seen or "unknown",
|
|
||||||
"usage": None,
|
|
||||||
}
|
|
||||||
cost_data = await adjust_payment_for_tokens(
|
|
||||||
fresh_key, fallback, new_session, max_cost_for_model
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
return f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
|
|
||||||
except Exception:
|
|
||||||
usage_finalized = True
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async for chunk in response.aiter_bytes():
|
|
||||||
stored_chunks.append(chunk)
|
|
||||||
try:
|
|
||||||
decoded_chunk = chunk.decode("utf-8", errors="ignore")
|
|
||||||
for line in decoded_chunk.split("\n"):
|
|
||||||
if line.startswith("data: "):
|
|
||||||
try:
|
|
||||||
data = json.loads(line[6:])
|
|
||||||
if isinstance(data, dict):
|
|
||||||
msg = data.get("message", {})
|
|
||||||
if msg and msg.get("model"):
|
|
||||||
last_model_seen = str(msg.get("model"))
|
|
||||||
|
|
||||||
if usage := msg.get("usage"):
|
|
||||||
input_tokens += usage.get("input_tokens", 0)
|
|
||||||
output_tokens += usage.get(
|
|
||||||
"output_tokens", 0
|
|
||||||
)
|
|
||||||
|
|
||||||
if usage := data.get("usage"):
|
|
||||||
input_tokens += usage.get("input_tokens", 0)
|
|
||||||
output_tokens += usage.get(
|
|
||||||
"output_tokens", 0
|
|
||||||
)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
yield chunk
|
|
||||||
|
|
||||||
usage_data = {
|
|
||||||
"input_tokens": input_tokens,
|
|
||||||
"output_tokens": output_tokens,
|
|
||||||
}
|
|
||||||
|
|
||||||
if input_tokens > 0 or output_tokens > 0:
|
|
||||||
async with create_session() as new_session:
|
|
||||||
fresh_key = await new_session.get(key.__class__, key.hashed_key)
|
|
||||||
if fresh_key:
|
|
||||||
try:
|
|
||||||
combined_data = {
|
|
||||||
"model": last_model_seen or "unknown",
|
|
||||||
"usage": usage_data,
|
|
||||||
}
|
|
||||||
cost_data = await adjust_payment_for_tokens(
|
|
||||||
fresh_key,
|
|
||||||
combined_data,
|
|
||||||
new_session,
|
|
||||||
max_cost_for_model,
|
|
||||||
)
|
|
||||||
usage_finalized = True
|
|
||||||
yield f"event: cost\ndata: {json.dumps({'cost': cost_data})}\n\n".encode()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not usage_finalized:
|
|
||||||
maybe_cost_event = await finalize_without_usage()
|
|
||||||
if maybe_cost_event is not None:
|
|
||||||
yield maybe_cost_event
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
if not usage_finalized:
|
|
||||||
await finalize_without_usage()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
if not usage_finalized:
|
|
||||||
await finalize_without_usage()
|
|
||||||
|
|
||||||
response_headers = dict(response.headers)
|
|
||||||
response_headers.pop("content-encoding", None)
|
|
||||||
response_headers.pop("content-length", None)
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
stream_with_cost(max_cost_for_model),
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=response_headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_non_streaming_messages_completion(
|
|
||||||
self,
|
|
||||||
response: httpx.Response,
|
|
||||||
key: ApiKey,
|
|
||||||
session: AsyncSession,
|
|
||||||
deducted_max_cost: int,
|
|
||||||
path: str,
|
|
||||||
) -> Response:
|
|
||||||
try:
|
|
||||||
content = await response.aread()
|
|
||||||
response_json = json.loads(content)
|
|
||||||
|
|
||||||
if path.endswith("count_tokens") and "usage" not in response_json:
|
|
||||||
input_tokens = response_json.get("input_tokens", 0)
|
|
||||||
response_json["usage"] = {"input_tokens": input_tokens}
|
|
||||||
|
|
||||||
cost_data = await adjust_payment_for_tokens(
|
|
||||||
key, response_json, session, deducted_max_cost
|
|
||||||
)
|
|
||||||
response_json["cost"] = cost_data
|
|
||||||
|
|
||||||
allowed_headers = {
|
|
||||||
"content-type",
|
|
||||||
"cache-control",
|
|
||||||
"date",
|
|
||||||
"vary",
|
|
||||||
"access-control-allow-origin",
|
|
||||||
"access-control-allow-methods",
|
|
||||||
"access-control-allow-headers",
|
|
||||||
"access-control-allow-credentials",
|
|
||||||
"access-control-expose-headers",
|
|
||||||
"access-control-max-age",
|
|
||||||
}
|
|
||||||
|
|
||||||
response_headers = {
|
|
||||||
k: v
|
|
||||||
for k, v in response.headers.items()
|
|
||||||
if k.lower() in allowed_headers
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response(
|
|
||||||
content=json.dumps(response_json).encode(),
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=response_headers,
|
|
||||||
media_type="application/json",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def forward_request(
|
async def forward_request(
|
||||||
self,
|
self,
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -1312,6 +1083,14 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
if response.status_code >= 500:
|
||||||
|
await response.aclose()
|
||||||
|
await client.aclose()
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Upstream returned status {response.status_code}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mapped_error = await self.map_upstream_error_response(
|
mapped_error = await self.map_upstream_error_response(
|
||||||
request, path, response
|
request, path, response
|
||||||
@@ -1321,54 +1100,7 @@ class BaseUpstreamProvider:
|
|||||||
await client.aclose()
|
await client.aclose()
|
||||||
return mapped_error
|
return mapped_error
|
||||||
|
|
||||||
if (
|
if path.endswith("chat/completions") or path.endswith("embeddings"):
|
||||||
path.endswith("chat/completions")
|
|
||||||
or path.endswith("embeddings")
|
|
||||||
or path.endswith("messages")
|
|
||||||
or path.endswith("messages/count_tokens")
|
|
||||||
):
|
|
||||||
if path.endswith("messages"):
|
|
||||||
client_wants_streaming = False
|
|
||||||
if request_body:
|
|
||||||
try:
|
|
||||||
request_data = json.loads(request_body)
|
|
||||||
client_wants_streaming = request_data.get("stream", False)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
content_type = response.headers.get("content-type", "")
|
|
||||||
upstream_is_streaming = "text/event-stream" in content_type
|
|
||||||
is_streaming = client_wants_streaming and upstream_is_streaming
|
|
||||||
|
|
||||||
if is_streaming and response.status_code == 200:
|
|
||||||
result = await self.handle_streaming_messages_completion(
|
|
||||||
response, key, max_cost_for_model
|
|
||||||
)
|
|
||||||
background_tasks = BackgroundTasks()
|
|
||||||
background_tasks.add_task(response.aclose)
|
|
||||||
background_tasks.add_task(client.aclose)
|
|
||||||
result.background = background_tasks
|
|
||||||
return result
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
try:
|
|
||||||
return await self.handle_non_streaming_messages_completion(
|
|
||||||
response, key, session, max_cost_for_model, path
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await response.aclose()
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
if path.endswith("messages/count_tokens"):
|
|
||||||
if response.status_code == 200:
|
|
||||||
try:
|
|
||||||
return await self.handle_non_streaming_messages_completion(
|
|
||||||
response, key, session, max_cost_for_model, path
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
await response.aclose()
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
if path.endswith("chat/completions"):
|
if path.endswith("chat/completions"):
|
||||||
client_wants_streaming = False
|
client_wants_streaming = False
|
||||||
if request_body:
|
if request_body:
|
||||||
@@ -1449,6 +1181,9 @@ class BaseUpstreamProvider:
|
|||||||
background=background_tasks,
|
background=background_tasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except UpstreamError:
|
||||||
|
raise
|
||||||
|
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
error_type = type(exc).__name__
|
error_type = type(exc).__name__
|
||||||
@@ -1476,9 +1211,7 @@ class BaseUpstreamProvider:
|
|||||||
else:
|
else:
|
||||||
error_message = f"Error connecting to upstream service: {error_type}"
|
error_message = f"Error connecting to upstream service: {error_type}"
|
||||||
|
|
||||||
return create_error_response(
|
raise UpstreamError(error_message, status_code=502)
|
||||||
"upstream_error", error_message, 502, request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
@@ -1591,6 +1324,14 @@ class BaseUpstreamProvider:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
|
if response.status_code >= 500:
|
||||||
|
await response.aclose()
|
||||||
|
await client.aclose()
|
||||||
|
raise UpstreamError(
|
||||||
|
f"Upstream returned status {response.status_code}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mapped_error = await self.map_upstream_error_response(
|
mapped_error = await self.map_upstream_error_response(
|
||||||
request, path, response
|
request, path, response
|
||||||
@@ -1658,6 +1399,9 @@ class BaseUpstreamProvider:
|
|||||||
background=background_tasks,
|
background=background_tasks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
except UpstreamError:
|
||||||
|
raise
|
||||||
|
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
error_type = type(exc).__name__
|
error_type = type(exc).__name__
|
||||||
@@ -1685,9 +1429,7 @@ class BaseUpstreamProvider:
|
|||||||
else:
|
else:
|
||||||
error_message = f"Error connecting to upstream service: {error_type}"
|
error_message = f"Error connecting to upstream service: {error_type}"
|
||||||
|
|
||||||
return create_error_response(
|
raise UpstreamError(error_message, status_code=502)
|
||||||
"upstream_error", error_message, 502, request=request
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await client.aclose()
|
await client.aclose()
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import random
|
|
||||||
from typing import AsyncIterator
|
|
||||||
|
|
||||||
from fastapi import Request
|
|
||||||
from fastapi.responses import Response, StreamingResponse
|
|
||||||
|
|
||||||
from ..core.db import ApiKey, AsyncSession
|
|
||||||
from ..payment.models import Architecture, Model, Pricing
|
|
||||||
from .base import BaseUpstreamProvider
|
|
||||||
|
|
||||||
|
|
||||||
class MockUpstreamProvider(BaseUpstreamProvider):
|
|
||||||
"""Fack Mock Upstream provider specifically for Testing."""
|
|
||||||
|
|
||||||
provider_type = "mock"
|
|
||||||
|
|
||||||
async def forward_request(
|
|
||||||
self,
|
|
||||||
request: Request,
|
|
||||||
path: str,
|
|
||||||
headers: dict,
|
|
||||||
request_body: bytes | None,
|
|
||||||
key: ApiKey,
|
|
||||||
max_cost_for_model: int,
|
|
||||||
session: AsyncSession,
|
|
||||||
model_obj: Model,
|
|
||||||
) -> Response | StreamingResponse:
|
|
||||||
if path.endswith("chat/completions"):
|
|
||||||
is_streaming = False
|
|
||||||
if request_body:
|
|
||||||
request_data = json.loads(request_body)
|
|
||||||
is_streaming = request_data.get("stream", False)
|
|
||||||
|
|
||||||
if is_streaming:
|
|
||||||
|
|
||||||
async def fake_streaming_response(
|
|
||||||
chunk_size: int | None = None,
|
|
||||||
) -> AsyncIterator[bytes]:
|
|
||||||
suffix = random.randint(1000, 9999)
|
|
||||||
req_id = f"gen-mock-stream-{suffix}"
|
|
||||||
created = 1766138895
|
|
||||||
model = "mock/gpt-420-mock"
|
|
||||||
|
|
||||||
def make_chunk(
|
|
||||||
delta: dict,
|
|
||||||
finish_reason: str | None = None,
|
|
||||||
usage: dict | None = None,
|
|
||||||
) -> bytes:
|
|
||||||
chunk = {
|
|
||||||
"id": req_id,
|
|
||||||
"provider": "MockProvider",
|
|
||||||
"model": model,
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"created": created,
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"delta": delta,
|
|
||||||
"finish_reason": finish_reason,
|
|
||||||
"native_finish_reason": "completed"
|
|
||||||
if finish_reason
|
|
||||||
else None,
|
|
||||||
"logprobs": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if usage:
|
|
||||||
chunk["usage"] = usage
|
|
||||||
return f"data: {json.dumps(chunk)}\n\n".encode()
|
|
||||||
|
|
||||||
# 1. Initial chunk
|
|
||||||
yield make_chunk({"role": "assistant", "content": ""})
|
|
||||||
await asyncio.sleep(0.02)
|
|
||||||
|
|
||||||
# 2. Reasoning chunks
|
|
||||||
reasoning_tokens = ["Mock", " reason", "ing", "..."]
|
|
||||||
for token in reasoning_tokens:
|
|
||||||
delta = {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": "",
|
|
||||||
"reasoning": token,
|
|
||||||
"reasoning_details": [
|
|
||||||
{
|
|
||||||
"type": "reasoning.summary",
|
|
||||||
"summary": token,
|
|
||||||
"format": "openai-responses-v1",
|
|
||||||
"index": 0,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
yield make_chunk(delta)
|
|
||||||
await asyncio.sleep(0.03)
|
|
||||||
|
|
||||||
# 3. Content chunks
|
|
||||||
content_tokens = ["This", " is", " a", " mock", " stream", "."]
|
|
||||||
for token in content_tokens:
|
|
||||||
yield make_chunk({"role": "assistant", "content": token})
|
|
||||||
await asyncio.sleep(0.03)
|
|
||||||
|
|
||||||
# 4. Finish chunk
|
|
||||||
yield make_chunk(
|
|
||||||
{"role": "assistant", "content": ""}, finish_reason="stop"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. Usage chunk
|
|
||||||
usage_data = {
|
|
||||||
"prompt_tokens": 10,
|
|
||||||
"completion_tokens": 20,
|
|
||||||
"total_tokens": 30,
|
|
||||||
"cost": 0.001,
|
|
||||||
"is_byok": False,
|
|
||||||
"prompt_tokens_details": {
|
|
||||||
"cached_tokens": 0,
|
|
||||||
"audio_tokens": 0,
|
|
||||||
"video_tokens": 0,
|
|
||||||
},
|
|
||||||
"cost_details": {
|
|
||||||
"upstream_inference_cost": None,
|
|
||||||
"upstream_inference_prompt_cost": 0,
|
|
||||||
"upstream_inference_completions_cost": 0.001,
|
|
||||||
},
|
|
||||||
"completion_tokens_details": {
|
|
||||||
"reasoning_tokens": 10,
|
|
||||||
"image_tokens": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
usage_chunk = {
|
|
||||||
"id": req_id,
|
|
||||||
"provider": "MockProvider",
|
|
||||||
"model": model,
|
|
||||||
"object": "chat.completion.chunk",
|
|
||||||
"created": created,
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"index": 0,
|
|
||||||
"delta": {"role": "assistant", "content": ""},
|
|
||||||
"finish_reason": None,
|
|
||||||
"native_finish_reason": None,
|
|
||||||
"logprobs": None,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": usage_data,
|
|
||||||
}
|
|
||||||
yield f"data: {json.dumps(usage_chunk)}\n\n".encode()
|
|
||||||
|
|
||||||
# 6. DONE
|
|
||||||
yield b"data: [DONE]\n\n"
|
|
||||||
|
|
||||||
# 7. Cost
|
|
||||||
cost_chunk = {
|
|
||||||
"cost": {
|
|
||||||
"base_msats": 0,
|
|
||||||
"input_msats": 2,
|
|
||||||
"output_msats": 10,
|
|
||||||
"total_msats": 12,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
yield f"data: {json.dumps(cost_chunk)}\n\n".encode()
|
|
||||||
|
|
||||||
return StreamingResponse(
|
|
||||||
fake_streaming_response(),
|
|
||||||
200,
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
suffix = random.randint(1000, 9999)
|
|
||||||
content_dict = {
|
|
||||||
"id": f"gen-mock-{suffix}",
|
|
||||||
"provider": "MockProvider",
|
|
||||||
"model": "mock/gpt-5-mini",
|
|
||||||
"object": "chat.completion",
|
|
||||||
"created": 1766138655,
|
|
||||||
"choices": [
|
|
||||||
{
|
|
||||||
"logprobs": None,
|
|
||||||
"finish_reason": "length",
|
|
||||||
"native_finish_reason": "max_output_tokens",
|
|
||||||
"index": 0,
|
|
||||||
"message": {
|
|
||||||
"role": "assistant",
|
|
||||||
"content": f"Mock Content {suffix}",
|
|
||||||
"refusal": None,
|
|
||||||
"reasoning": f"Mock Reasoning {suffix}",
|
|
||||||
"reasoning_details": [
|
|
||||||
{
|
|
||||||
"format": "openai-responses-v1",
|
|
||||||
"index": 0,
|
|
||||||
"type": "reasoning.summary",
|
|
||||||
"summary": f"Mock Summary {suffix}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": f"rs_mock_{suffix}",
|
|
||||||
"format": "openai-responses-v1",
|
|
||||||
"index": 0,
|
|
||||||
"type": "reasoning.encrypted",
|
|
||||||
"data": "mock_encrypted_data",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"usage": {
|
|
||||||
"prompt_tokens": 10,
|
|
||||||
"completion_tokens": 10,
|
|
||||||
"total_tokens": 20,
|
|
||||||
"cost": 0,
|
|
||||||
"is_byok": False,
|
|
||||||
"prompt_tokens_details": {
|
|
||||||
"cached_tokens": 0,
|
|
||||||
"audio_tokens": 0,
|
|
||||||
"video_tokens": 0,
|
|
||||||
},
|
|
||||||
"cost_details": {
|
|
||||||
"upstream_inference_cost": None,
|
|
||||||
"upstream_inference_prompt_cost": 0,
|
|
||||||
"upstream_inference_completions_cost": 0,
|
|
||||||
},
|
|
||||||
"completion_tokens_details": {
|
|
||||||
"reasoning_tokens": 5,
|
|
||||||
"image_tokens": 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"cost": {
|
|
||||||
"base_msats": 0,
|
|
||||||
"input_msats": 0,
|
|
||||||
"output_msats": 0,
|
|
||||||
"total_msats": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return Response(json.dumps(content_dict).encode(), 200)
|
|
||||||
|
|
||||||
elif path.endswith("embeddings"):
|
|
||||||
raise NotImplementedError
|
|
||||||
elif path.endswith("responses"):
|
|
||||||
raise NotImplementedError
|
|
||||||
else:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
async def fetch_models(self) -> list[Model]:
|
|
||||||
return [
|
|
||||||
Model(
|
|
||||||
id="mock/gpt-420-mock",
|
|
||||||
name="mock/gpt-420-mock",
|
|
||||||
created=0,
|
|
||||||
description="mock model for testing",
|
|
||||||
context_length=8192,
|
|
||||||
architecture=Architecture(
|
|
||||||
modality="text",
|
|
||||||
input_modalities=["text"],
|
|
||||||
output_modalities=["text"],
|
|
||||||
tokenizer="",
|
|
||||||
instruct_type=None,
|
|
||||||
),
|
|
||||||
pricing=Pricing(prompt=0.01, completion=0.01),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def transform_model_name(self, model_id: str) -> str:
|
|
||||||
return "fake-model"
|
|
||||||
|
|
||||||
async def get_balance(self) -> float | None:
|
|
||||||
return 420.69
|
|
||||||
+23
-22
@@ -102,6 +102,8 @@ async def get_all_models_with_overrides(
|
|||||||
)
|
)
|
||||||
for row in override_rows
|
for row in override_rows
|
||||||
if row.upstream_provider_id is not None
|
if row.upstream_provider_id is not None
|
||||||
|
and row.upstream_provider_id in providers_by_id
|
||||||
|
and providers_by_id[row.upstream_provider_id].enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
all_models: dict[str, Model] = {}
|
all_models: dict[str, Model] = {}
|
||||||
@@ -218,14 +220,6 @@ async def init_upstreams() -> list[BaseUpstreamProvider]:
|
|||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
upstreams = [p for p in results if p is not None]
|
upstreams = [p for p in results if p is not None]
|
||||||
|
|
||||||
if "https://testnut.cashu.space" in settings.cashu_mints:
|
|
||||||
from .fake import MockUpstreamProvider
|
|
||||||
|
|
||||||
mock_provider = MockUpstreamProvider("mock", "mock")
|
|
||||||
await mock_provider.refresh_models_cache()
|
|
||||||
upstreams.append(mock_provider)
|
|
||||||
logger.info("Initialized MockUpstreamProvider for testnut mint")
|
|
||||||
|
|
||||||
return upstreams
|
return upstreams
|
||||||
|
|
||||||
|
|
||||||
@@ -242,7 +236,7 @@ async def _seed_providers_from_settings(
|
|||||||
from . import upstream_provider_classes
|
from . import upstream_provider_classes
|
||||||
|
|
||||||
providers_to_add: list[UpstreamProviderRow] = []
|
providers_to_add: list[UpstreamProviderRow] = []
|
||||||
seeded_base_urls: set[str] = set()
|
seeded_provider_keys: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
provider_classes_by_type = {
|
provider_classes_by_type = {
|
||||||
cls.provider_type: cls
|
cls.provider_type: cls
|
||||||
@@ -267,7 +261,8 @@ async def _seed_providers_from_settings(
|
|||||||
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
base_url = provider_class.default_base_url # type: ignore[attr-defined]
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == base_url
|
UpstreamProviderRow.base_url == base_url,
|
||||||
|
UpstreamProviderRow.api_key == api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -279,13 +274,15 @@ async def _seed_providers_from_settings(
|
|||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(base_url)
|
seeded_provider_keys.add((base_url, api_key))
|
||||||
|
|
||||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
||||||
if ollama_base_url:
|
if ollama_base_url:
|
||||||
|
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "")
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == ollama_base_url
|
UpstreamProviderRow.base_url == ollama_base_url,
|
||||||
|
UpstreamProviderRow.api_key == ollama_api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -293,18 +290,20 @@ async def _seed_providers_from_settings(
|
|||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
provider_type="ollama",
|
provider_type="ollama",
|
||||||
base_url=ollama_base_url,
|
base_url=ollama_base_url,
|
||||||
api_key=os.environ.get("OLLAMA_API_KEY", ""),
|
api_key=ollama_api_key,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(ollama_base_url)
|
seeded_provider_keys.add((ollama_base_url, ollama_api_key))
|
||||||
|
|
||||||
if settings.chat_completions_api_version and settings.upstream_base_url:
|
if settings.chat_completions_api_version and settings.upstream_base_url:
|
||||||
base_url = settings.upstream_base_url
|
base_url = settings.upstream_base_url
|
||||||
if base_url not in seeded_base_urls:
|
api_key = settings.upstream_api_key
|
||||||
|
if (base_url, api_key) not in seeded_provider_keys:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == base_url
|
UpstreamProviderRow.base_url == base_url,
|
||||||
|
UpstreamProviderRow.api_key == api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -312,19 +311,21 @@ async def _seed_providers_from_settings(
|
|||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
provider_type="azure",
|
provider_type="azure",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=settings.upstream_api_key,
|
api_key=api_key,
|
||||||
api_version=settings.chat_completions_api_version,
|
api_version=settings.chat_completions_api_version,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(base_url)
|
seeded_provider_keys.add((base_url, api_key))
|
||||||
|
|
||||||
if settings.upstream_base_url and settings.upstream_api_key:
|
if settings.upstream_base_url and settings.upstream_api_key:
|
||||||
base_url = settings.upstream_base_url
|
base_url = settings.upstream_base_url
|
||||||
if base_url not in seeded_base_urls:
|
api_key = settings.upstream_api_key
|
||||||
|
if (base_url, api_key) not in seeded_provider_keys:
|
||||||
result = await session.exec(
|
result = await session.exec(
|
||||||
select(UpstreamProviderRow).where(
|
select(UpstreamProviderRow).where(
|
||||||
UpstreamProviderRow.base_url == base_url
|
UpstreamProviderRow.base_url == base_url,
|
||||||
|
UpstreamProviderRow.api_key == api_key,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not result.first():
|
if not result.first():
|
||||||
@@ -332,11 +333,11 @@ async def _seed_providers_from_settings(
|
|||||||
UpstreamProviderRow(
|
UpstreamProviderRow(
|
||||||
provider_type="custom",
|
provider_type="custom",
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
api_key=settings.upstream_api_key,
|
api_key=api_key,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
seeded_base_urls.add(base_url)
|
seeded_provider_keys.add((base_url, api_key))
|
||||||
|
|
||||||
for provider in providers_to_add:
|
for provider in providers_to_add:
|
||||||
session.add(provider)
|
session.add(provider)
|
||||||
|
|||||||
@@ -196,6 +196,37 @@ class PPQAIUpstreamProvider(BaseUpstreamProvider):
|
|||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def on_upstream_error_redirect(
|
||||||
|
self, status_code: int, error_message: str
|
||||||
|
) -> None:
|
||||||
|
if "insufficient balance" in error_message.lower():
|
||||||
|
logger.warning(
|
||||||
|
f"Disabling PPQ.AI provider ({self.base_url}) due to insufficient balance",
|
||||||
|
extra={"error": error_message},
|
||||||
|
)
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from ..core.db import UpstreamProviderRow, create_session
|
||||||
|
|
||||||
|
async with create_session() as session:
|
||||||
|
statement = select(UpstreamProviderRow).where(
|
||||||
|
UpstreamProviderRow.base_url == self.base_url,
|
||||||
|
UpstreamProviderRow.api_key == self.api_key,
|
||||||
|
)
|
||||||
|
result = await session.exec(statement)
|
||||||
|
provider = result.first()
|
||||||
|
|
||||||
|
if provider:
|
||||||
|
provider.enabled = False
|
||||||
|
session.add(provider)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Trigger re-initialization of providers
|
||||||
|
# Import here to avoid circular dependency
|
||||||
|
from ..proxy import reinitialize_upstreams
|
||||||
|
|
||||||
|
await reinitialize_upstreams()
|
||||||
|
|
||||||
async def create_account(self) -> dict[str, object]:
|
async def create_account(self) -> dict[str, object]:
|
||||||
"""Create a new PPQ.AI account.
|
"""Create a new PPQ.AI account.
|
||||||
|
|
||||||
|
|||||||
@@ -313,8 +313,6 @@ async def periodic_payout() -> None:
|
|||||||
try:
|
try:
|
||||||
async with db.create_session() as session:
|
async with db.create_session() as session:
|
||||||
for mint_url in settings.cashu_mints:
|
for mint_url in settings.cashu_mints:
|
||||||
if mint_url == "https://testnut.cashu.space":
|
|
||||||
continue
|
|
||||||
for unit in ["sat", "msat"]:
|
for unit in ["sat", "msat"]:
|
||||||
wallet = await get_wallet(mint_url, unit)
|
wallet = await get_wallet(mint_url, unit)
|
||||||
proofs = get_proofs_per_mint_and_unit(
|
proofs = get_proofs_per_mint_and_unit(
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import secrets
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from routstr.auth import adjust_payment_for_tokens, pay_for_request
|
||||||
|
from routstr.balance import ChildKeyRequest, create_child_key
|
||||||
|
from routstr.core.db import ApiKey
|
||||||
|
from routstr.core.settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_child_key_flow(integration_session: AsyncSession) -> None:
|
||||||
|
# 1. Create a parent key with balance
|
||||||
|
parent_raw = "parent_test_key_" + secrets.token_hex(4)
|
||||||
|
parent_key = ApiKey(
|
||||||
|
hashed_key=parent_raw,
|
||||||
|
balance=10000, # 10 sats
|
||||||
|
)
|
||||||
|
integration_session.add(parent_key)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
|
||||||
|
# Mock settings
|
||||||
|
settings.child_key_cost = 1000 # 1 sat
|
||||||
|
|
||||||
|
# 2. Call create_child_key
|
||||||
|
result = await create_child_key(
|
||||||
|
ChildKeyRequest(count=1), parent_key, integration_session
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "api_keys" in result
|
||||||
|
assert result["cost_msats"] == 1000
|
||||||
|
assert result["parent_balance"] == 9000
|
||||||
|
|
||||||
|
child_key_raw = result["api_keys"][0][3:] # remove sk-
|
||||||
|
|
||||||
|
# 3. Verify child key exists in DB
|
||||||
|
child_key_db = await integration_session.get(ApiKey, child_key_raw)
|
||||||
|
assert child_key_db is not None
|
||||||
|
assert child_key_db.parent_key_hash == parent_key.hashed_key
|
||||||
|
assert child_key_db.balance == 0
|
||||||
|
|
||||||
|
# 4. Test payment with child key
|
||||||
|
cost = 500
|
||||||
|
await pay_for_request(child_key_db, cost, integration_session)
|
||||||
|
|
||||||
|
# Refresh keys
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
await integration_session.refresh(child_key_db)
|
||||||
|
|
||||||
|
# Parent should be charged
|
||||||
|
assert parent_key.reserved_balance == 500
|
||||||
|
assert parent_key.total_requests == 1
|
||||||
|
|
||||||
|
# Child should have total_requests incremented
|
||||||
|
assert child_key_db.total_requests == 1
|
||||||
|
|
||||||
|
# 5. Test adjustment
|
||||||
|
response_data = {"model": "test-model", "usage": {"total_tokens": 10}}
|
||||||
|
|
||||||
|
# Mock calculate_cost
|
||||||
|
import routstr.auth
|
||||||
|
from routstr.payment.cost_calculation import CostData
|
||||||
|
|
||||||
|
async def mock_calculate_cost(*args: Any, **kwargs: Any) -> CostData:
|
||||||
|
return CostData(
|
||||||
|
base_msats=0, input_msats=200, output_msats=200, total_msats=400
|
||||||
|
)
|
||||||
|
|
||||||
|
# Patch calculate_cost
|
||||||
|
original_calculate_cost = routstr.auth.calculate_cost
|
||||||
|
routstr.auth.calculate_cost = mock_calculate_cost
|
||||||
|
|
||||||
|
try:
|
||||||
|
adjustment = await adjust_payment_for_tokens(
|
||||||
|
child_key_db, response_data, integration_session, 500
|
||||||
|
)
|
||||||
|
assert adjustment["total_msats"] == 400
|
||||||
|
|
||||||
|
# Refresh keys
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
await integration_session.refresh(child_key_db)
|
||||||
|
|
||||||
|
# Parent should have updated balance and total_spent
|
||||||
|
assert parent_key.reserved_balance == 0
|
||||||
|
assert parent_key.balance == 9000 - 400
|
||||||
|
assert (
|
||||||
|
parent_key.total_spent == 1400
|
||||||
|
) # 1000 for child key creation + 400 for request
|
||||||
|
|
||||||
|
# Child should also have total_spent updated
|
||||||
|
assert child_key_db.total_spent == 400
|
||||||
|
|
||||||
|
finally:
|
||||||
|
routstr.auth.calculate_cost = original_calculate_cost
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_child_key_insufficient_balance(
|
||||||
|
integration_session: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
parent_key = ApiKey(
|
||||||
|
hashed_key="poor_parent_" + secrets.token_hex(4),
|
||||||
|
balance=500,
|
||||||
|
)
|
||||||
|
integration_session.add(parent_key)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(parent_key)
|
||||||
|
|
||||||
|
settings.child_key_cost = 1000
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await create_child_key(
|
||||||
|
ChildKeyRequest(count=1), parent_key, integration_session
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_child_key_cannot_create_child(integration_session: AsyncSession) -> None:
|
||||||
|
parent_key = ApiKey(
|
||||||
|
hashed_key="parent_" + secrets.token_hex(4),
|
||||||
|
balance=10000,
|
||||||
|
)
|
||||||
|
child_key = ApiKey(
|
||||||
|
hashed_key="child_" + secrets.token_hex(4),
|
||||||
|
balance=0,
|
||||||
|
parent_key_hash=parent_key.hashed_key,
|
||||||
|
)
|
||||||
|
integration_session.add(parent_key)
|
||||||
|
integration_session.add(child_key)
|
||||||
|
await integration_session.commit()
|
||||||
|
await integration_session.refresh(child_key)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await create_child_key(ChildKeyRequest(count=1), child_key, integration_session)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "Cannot create a child key for another child key" in str(exc.value.detail)
|
||||||
@@ -10,7 +10,6 @@ from httpx import AsyncClient
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
CashuTokenGenerator,
|
CashuTokenGenerator,
|
||||||
PerformanceValidator,
|
|
||||||
ResponseValidator,
|
ResponseValidator,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,30 +158,7 @@ async def test_error_handling(
|
|||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_performance_requirements(integration_client: AsyncClient) -> None:
|
|
||||||
"""Test that endpoints meet performance requirements"""
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
# Test info endpoint performance
|
|
||||||
for i in range(50):
|
|
||||||
start = validator.start_timing("info_endpoint")
|
|
||||||
response = await integration_client.get("/")
|
|
||||||
validator.end_timing("info_endpoint", start)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate 95th percentile is under 500ms
|
|
||||||
result = validator.validate_response_time(
|
|
||||||
"info_endpoint", max_duration=0.5, percentile=0.95
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result["valid"], (
|
|
||||||
f"Performance requirement failed: "
|
|
||||||
f"95th percentile was {result['percentile_time']:.3f}s "
|
|
||||||
f"(required < {result['max_allowed']}s)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import gc
|
|||||||
import statistics
|
import statistics
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import psutil
|
import psutil
|
||||||
import pytest
|
import pytest
|
||||||
@@ -105,42 +106,46 @@ class TestPerformanceBaseline:
|
|||||||
("GET", "/v1/wallet/info", authenticated_client, None),
|
("GET", "/v1/wallet/info", authenticated_client, None),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Warm up
|
# Enable provider discovery for this test
|
||||||
for _ in range(10):
|
with patch(
|
||||||
await integration_client.get("/")
|
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
|
||||||
|
):
|
||||||
|
# Warm up
|
||||||
|
for _ in range(10):
|
||||||
|
await integration_client.get("/")
|
||||||
|
|
||||||
# Test each endpoint
|
# Test each endpoint
|
||||||
for method, path, client, data in endpoints:
|
for method, path, client, data in endpoints:
|
||||||
response_times = []
|
response_times = []
|
||||||
|
|
||||||
for i in range(100):
|
for i in range(100):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
response = await client.get(path)
|
response = await client.get(path)
|
||||||
else:
|
else:
|
||||||
response = await client.post(path, json=data)
|
response = await client.post(path, json=data)
|
||||||
|
|
||||||
duration = time.time() - start
|
duration = time.time() - start
|
||||||
response_times.append(duration * 1000) # Convert to ms
|
response_times.append(duration * 1000) # Convert to ms
|
||||||
|
|
||||||
assert response.status_code in [200, 201]
|
assert response.status_code in [200, 201]
|
||||||
|
|
||||||
if i % 10 == 0:
|
if i % 10 == 0:
|
||||||
metrics.record_system_metrics()
|
metrics.record_system_metrics()
|
||||||
|
|
||||||
# Verify 95th percentile < 500ms
|
# Verify 95th percentile < 500ms
|
||||||
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
|
p95 = sorted(response_times)[int(len(response_times) * 0.95)]
|
||||||
assert p95 < 500, (
|
assert p95 < 500, (
|
||||||
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
|
f"{method} {path} p95 response time {p95}ms exceeds 500ms limit"
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"\n{method} {path}:")
|
print(f"\n{method} {path}:")
|
||||||
print(f" Mean: {statistics.mean(response_times):.2f}ms")
|
print(f" Mean: {statistics.mean(response_times):.2f}ms")
|
||||||
print(f" P95: {p95:.2f}ms")
|
print(f" P95: {p95:.2f}ms")
|
||||||
print(
|
print(
|
||||||
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
|
f" P99: {sorted(response_times)[int(len(response_times) * 0.99)]:.2f}ms"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Integration tests for provider management functionality.
|
|||||||
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
Tests GET /v1/providers/ endpoint for listing and managing providers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any, Generator
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,7 +11,7 @@ from httpx import AsyncClient
|
|||||||
|
|
||||||
from routstr.discovery import _PROVIDERS_CACHE
|
from routstr.discovery import _PROVIDERS_CACHE
|
||||||
|
|
||||||
from .utils import PerformanceValidator, ResponseValidator
|
from .utils import ResponseValidator
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -19,6 +19,15 @@ def _clear_providers_cache() -> None:
|
|||||||
_PROVIDERS_CACHE.clear()
|
_PROVIDERS_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _enable_provider_discovery() -> Generator[None, Any, Any]:
|
||||||
|
"""Enable provider discovery for all tests in this module"""
|
||||||
|
with patch(
|
||||||
|
"routstr.core.settings.settings.providers_refresh_interval_seconds", 300
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_providers_endpoint_default_response(
|
async def test_providers_endpoint_default_response(
|
||||||
@@ -518,46 +527,6 @@ async def test_providers_endpoint_response_format(
|
|||||||
assert isinstance(data_json["providers"], list)
|
assert isinstance(data_json["providers"], list)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_providers_endpoint_performance(integration_client: AsyncClient) -> None:
|
|
||||||
"""Test providers endpoint meets performance requirements"""
|
|
||||||
|
|
||||||
# Mock quick responses to avoid network delays
|
|
||||||
mock_events: list[dict[str, Any]] = [
|
|
||||||
{
|
|
||||||
"id": f"event{i}",
|
|
||||||
"content": f"Provider: http://provider{i}.onion",
|
|
||||||
"created_at": 1234567890 + i,
|
|
||||||
}
|
|
||||||
for i in range(5)
|
|
||||||
]
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
|
|
||||||
):
|
|
||||||
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
|
|
||||||
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
|
|
||||||
|
|
||||||
# Test multiple requests
|
|
||||||
for i in range(10):
|
|
||||||
start = validator.start_timing("providers_endpoint")
|
|
||||||
response = await integration_client.get("/v1/providers/")
|
|
||||||
validator.end_timing("providers_endpoint", start)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate performance (should be fast with mocked dependencies)
|
|
||||||
perf_result = validator.validate_response_time(
|
|
||||||
"providers_endpoint",
|
|
||||||
max_duration=2.0, # Allow more time since it involves multiple operations
|
|
||||||
percentile=0.95,
|
|
||||||
)
|
|
||||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_providers_endpoint_concurrent_requests(
|
async def test_providers_endpoint_concurrent_requests(
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ from routstr.core.db import ApiKey
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
ConcurrencyTester,
|
ConcurrencyTester,
|
||||||
PerformanceValidator,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -551,39 +550,7 @@ async def test_proxy_get_concurrent_requests(
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_proxy_get_performance_requirements(
|
|
||||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
"""Test that GET proxy requests meet performance requirements"""
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.request") as mock_request:
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.headers = {"content-type": "application/json"}
|
|
||||||
mock_response.json = MagicMock(return_value={"performance": "test"})
|
|
||||||
mock_response.text = '{"performance": "test"}'
|
|
||||||
mock_response.iter_bytes = AsyncMock(return_value=[b'{"performance": "test"}'])
|
|
||||||
mock_request.return_value = mock_response
|
|
||||||
|
|
||||||
# Test multiple requests for performance measurement
|
|
||||||
for i in range(20):
|
|
||||||
start = validator.start_timing("proxy_get")
|
|
||||||
response = await authenticated_client.get(f"/v1/perf-test-{i}")
|
|
||||||
validator.end_timing("proxy_get", start)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate performance requirements
|
|
||||||
perf_result = validator.validate_response_time(
|
|
||||||
"proxy_get",
|
|
||||||
max_duration=1.0, # Should complete within 1 second
|
|
||||||
percentile=0.95,
|
|
||||||
)
|
|
||||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ from httpx import ASGITransport, AsyncClient
|
|||||||
|
|
||||||
from .utils import (
|
from .utils import (
|
||||||
ConcurrencyTester,
|
ConcurrencyTester,
|
||||||
PerformanceValidator,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -290,55 +289,7 @@ async def test_proxy_post_unauthorized_access(integration_client: AsyncClient) -
|
|||||||
assert response.status_code in [400, 401]
|
assert response.status_code in [400, 401]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_proxy_post_performance(
|
|
||||||
integration_client: AsyncClient, authenticated_client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
"""Test POST endpoint performance requirements"""
|
|
||||||
|
|
||||||
test_payload = {
|
|
||||||
"model": "gpt-3.5-turbo",
|
|
||||||
"messages": [{"role": "user", "content": "Performance test"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
validator = PerformanceValidator()
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.send") as mock_send:
|
|
||||||
# Mock fast responses
|
|
||||||
async def mock_iter_bytes(*args: Any, **kwargs: Any) -> Any:
|
|
||||||
yield b'{"choices": [{"message": {"content": "Fast"}}], "usage": {"total_tokens": 5}}'
|
|
||||||
|
|
||||||
mock_response = AsyncMock()
|
|
||||||
mock_response.status_code = 200
|
|
||||||
mock_response.headers = {"content-type": "application/json"}
|
|
||||||
response_data = {
|
|
||||||
"choices": [{"message": {"content": "Fast"}}],
|
|
||||||
"usage": {"total_tokens": 5},
|
|
||||||
}
|
|
||||||
mock_response.text = json.dumps(response_data)
|
|
||||||
mock_response.json = AsyncMock(return_value=response_data)
|
|
||||||
mock_response.iter_bytes = mock_iter_bytes
|
|
||||||
mock_response.aiter_bytes = mock_iter_bytes
|
|
||||||
mock_send.return_value = mock_response
|
|
||||||
|
|
||||||
# Run multiple requests for performance measurement
|
|
||||||
for i in range(20):
|
|
||||||
start = validator.start_timing("proxy_post")
|
|
||||||
response = await authenticated_client.post(
|
|
||||||
"/v1/chat/completions", json=test_payload
|
|
||||||
)
|
|
||||||
validator.end_timing("proxy_post", start)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# Validate performance
|
|
||||||
perf_result = validator.validate_response_time(
|
|
||||||
"proxy_post",
|
|
||||||
max_duration=1.5, # Allow slightly more time for POST
|
|
||||||
percentile=0.95,
|
|
||||||
)
|
|
||||||
assert perf_result["valid"], f"Performance requirement failed: {perf_result}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Integration tests for wallet information retrieval endpoints.
|
|||||||
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
|
Tests GET /v1/wallet/ and GET /v1/wallet/info endpoints with various scenarios.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -367,30 +367,4 @@ async def test_wallet_info_with_special_characters_in_headers(
|
|||||||
# Note: Current implementation doesn't return refund_address in response
|
# Note: Current implementation doesn't return refund_address in response
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.slow
|
|
||||||
async def test_wallet_endpoints_performance(authenticated_client: AsyncClient) -> None:
|
|
||||||
"""Test wallet endpoints meet performance requirements"""
|
|
||||||
|
|
||||||
# Warm up
|
|
||||||
await authenticated_client.get("/v1/wallet/")
|
|
||||||
|
|
||||||
# Measure response times
|
|
||||||
response_times = []
|
|
||||||
|
|
||||||
for _ in range(50):
|
|
||||||
start_time = time.time()
|
|
||||||
response = await authenticated_client.get("/v1/wallet/")
|
|
||||||
end_time = time.time()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
response_times.append(end_time - start_time)
|
|
||||||
|
|
||||||
# Calculate statistics
|
|
||||||
avg_time = sum(response_times) / len(response_times)
|
|
||||||
max_time = max(response_times)
|
|
||||||
|
|
||||||
# Performance assertions
|
|
||||||
assert avg_time < 0.1 # Average should be under 100ms
|
|
||||||
assert max_time < 0.5 # No request should take more than 500ms
|
|
||||||
|
|||||||
@@ -537,42 +537,4 @@ async def test_refund_with_expired_key(
|
|||||||
assert response.json()["recipient"] == "expired@ln.address"
|
assert response.json()["recipient"] == "expired@ln.address"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.slow
|
|
||||||
async def test_refund_performance(
|
|
||||||
integration_client: AsyncClient, testmint_wallet: Any
|
|
||||||
) -> None:
|
|
||||||
"""Test refund endpoint performance"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
# Create multiple API keys
|
|
||||||
api_keys = []
|
|
||||||
for i in range(10):
|
|
||||||
token = await testmint_wallet.mint_tokens(100 + i)
|
|
||||||
# Use cashu token as Bearer auth to create API key
|
|
||||||
integration_client.headers["Authorization"] = f"Bearer {token}"
|
|
||||||
response = await integration_client.get("/v1/wallet/info")
|
|
||||||
assert response.status_code == 200
|
|
||||||
api_keys.append(response.json()["api_key"])
|
|
||||||
|
|
||||||
# Measure refund times
|
|
||||||
refund_times = []
|
|
||||||
|
|
||||||
for api_key in api_keys:
|
|
||||||
integration_client.headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
response = await integration_client.post("/v1/wallet/refund")
|
|
||||||
end_time = time.time()
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
refund_times.append(end_time - start_time)
|
|
||||||
|
|
||||||
# Performance assertions
|
|
||||||
avg_time = sum(refund_times) / len(refund_times)
|
|
||||||
max_time = max(refund_times)
|
|
||||||
|
|
||||||
assert avg_time < 0.5 # Average under 500ms
|
|
||||||
assert max_time < 1.0 # No refund takes more than 1 second
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ os.environ["UPSTREAM_API_KEY"] = "test"
|
|||||||
from routstr.algorithm import ( # noqa: E402
|
from routstr.algorithm import ( # noqa: E402
|
||||||
calculate_model_cost_score,
|
calculate_model_cost_score,
|
||||||
get_provider_penalty,
|
get_provider_penalty,
|
||||||
should_prefer_model,
|
|
||||||
)
|
)
|
||||||
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
|
||||||
|
|
||||||
@@ -100,100 +99,3 @@ def test_get_provider_penalty_openrouter() -> None:
|
|||||||
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
|
provider = create_test_provider("openrouter", "https://openrouter.ai/api/v1")
|
||||||
penalty = get_provider_penalty(provider)
|
penalty = get_provider_penalty(provider)
|
||||||
assert penalty == 1.001
|
assert penalty == 1.001
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_cheaper_wins() -> None:
|
|
||||||
"""Test that cheaper model is preferred."""
|
|
||||||
cheap_model = create_test_model("cheap", prompt_price=0.001, completion_price=0.002)
|
|
||||||
expensive_model = create_test_model(
|
|
||||||
"expensive", prompt_price=0.03, completion_price=0.06
|
|
||||||
)
|
|
||||||
|
|
||||||
provider1 = create_test_provider("provider1")
|
|
||||||
provider2 = create_test_provider("provider2")
|
|
||||||
|
|
||||||
# Cheaper model should win
|
|
||||||
assert should_prefer_model(
|
|
||||||
cheap_model, provider1, expensive_model, provider2, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
# More expensive model should not win
|
|
||||||
assert not should_prefer_model(
|
|
||||||
expensive_model, provider2, cheap_model, provider1, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_exact_match_wins() -> None:
|
|
||||||
"""Test that exact alias match beats cheaper price."""
|
|
||||||
# Make model IDs match the alias differently
|
|
||||||
exact_match = create_test_model(
|
|
||||||
"test-model", prompt_price=0.03, completion_price=0.06
|
|
||||||
)
|
|
||||||
no_match = create_test_model(
|
|
||||||
"other-model", prompt_price=0.001, completion_price=0.002
|
|
||||||
)
|
|
||||||
|
|
||||||
provider1 = create_test_provider("provider1")
|
|
||||||
provider2 = create_test_provider("provider2")
|
|
||||||
|
|
||||||
# Exact match should win even though it's more expensive
|
|
||||||
assert should_prefer_model(
|
|
||||||
exact_match, provider1, no_match, provider2, "test-model"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_openrouter_slight_penalty() -> None:
|
|
||||||
"""Test that OpenRouter has slight penalty compared to other providers."""
|
|
||||||
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
|
|
||||||
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
|
|
||||||
|
|
||||||
regular_provider = create_test_provider("regular", "http://provider.com")
|
|
||||||
openrouter_provider = create_test_provider(
|
|
||||||
"openrouter", "https://openrouter.ai/api/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Regular provider should be preferred over OpenRouter at same cost
|
|
||||||
assert should_prefer_model(
|
|
||||||
model1, regular_provider, model2, openrouter_provider, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
# OpenRouter should not replace regular provider at same cost
|
|
||||||
assert not should_prefer_model(
|
|
||||||
model2, openrouter_provider, model1, regular_provider, "test-alias"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_openrouter_can_win_if_cheaper() -> None:
|
|
||||||
"""Test that OpenRouter can still win if significantly cheaper."""
|
|
||||||
cheap_model = create_test_model(
|
|
||||||
"cheap", prompt_price=0.0001, completion_price=0.0002
|
|
||||||
)
|
|
||||||
expensive_model = create_test_model(
|
|
||||||
"expensive", prompt_price=0.03, completion_price=0.06
|
|
||||||
)
|
|
||||||
|
|
||||||
regular_provider = create_test_provider("regular", "http://provider.com")
|
|
||||||
openrouter_provider = create_test_provider(
|
|
||||||
"openrouter", "https://openrouter.ai/api/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
# OpenRouter should win if it's much cheaper (even with penalty)
|
|
||||||
assert should_prefer_model(
|
|
||||||
cheap_model,
|
|
||||||
openrouter_provider,
|
|
||||||
expensive_model,
|
|
||||||
regular_provider,
|
|
||||||
"test-alias",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_prefer_model_same_cost_first_wins() -> None:
|
|
||||||
"""Test that when costs are identical, current model is kept."""
|
|
||||||
model1 = create_test_model("model1", prompt_price=0.001, completion_price=0.002)
|
|
||||||
model2 = create_test_model("model2", prompt_price=0.001, completion_price=0.002)
|
|
||||||
|
|
||||||
provider1 = create_test_provider("provider1")
|
|
||||||
provider2 = create_test_provider("provider2")
|
|
||||||
|
|
||||||
# When costs are equal, should not replace
|
|
||||||
assert not should_prefer_model(model2, provider2, model1, provider1, "test-alias")
|
|
||||||
|
|||||||
+132
-161
@@ -286,7 +286,9 @@ function ProviderBalance({
|
|||||||
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
<div className='rounded-lg border-2 border-gray-200 p-2 dark:border-gray-800'>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(invoiceData.payment_request)}`}
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(
|
||||||
|
invoiceData.payment_request
|
||||||
|
)}`}
|
||||||
alt='Lightning Invoice QR Code'
|
alt='Lightning Invoice QR Code'
|
||||||
className='h-64 w-64'
|
className='h-64 w-64'
|
||||||
/>
|
/>
|
||||||
@@ -482,6 +484,25 @@ export default function ProvidersPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteModelMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
providerId,
|
||||||
|
modelId,
|
||||||
|
}: {
|
||||||
|
providerId: number;
|
||||||
|
modelId: string;
|
||||||
|
}) => AdminService.deleteProviderModel(providerId, modelId),
|
||||||
|
onSuccess: (_, variables) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['provider-models', variables.providerId],
|
||||||
|
});
|
||||||
|
toast.success('Model deleted successfully');
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(`Failed to delete model: ${error.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleCreateAccount = async () => {
|
const handleCreateAccount = async () => {
|
||||||
setIsCreatingAccount(true);
|
setIsCreatingAccount(true);
|
||||||
try {
|
try {
|
||||||
@@ -560,6 +581,12 @@ export default function ProvidersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteModel = (providerId: number, modelId: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this model?')) {
|
||||||
|
deleteModelMutation.mutate({ providerId, modelId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getDefaultBaseUrl = (type: string) => {
|
const getDefaultBaseUrl = (type: string) => {
|
||||||
const providerType = providerTypes.find((pt) => pt.id === type);
|
const providerType = providerTypes.find((pt) => pt.id === type);
|
||||||
return providerType?.default_base_url || '';
|
return providerType?.default_base_url || '';
|
||||||
@@ -933,25 +960,71 @@ export default function ProvidersPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : providerModels &&
|
) : providerModels &&
|
||||||
viewingModels === provider.id ? (
|
viewingModels === provider.id ? (
|
||||||
providerModels.remote_models.length === 0 ? (
|
<Tabs
|
||||||
// No provided models - show custom models directly without tabs
|
defaultValue={
|
||||||
<div className='space-y-2'>
|
providerModels.remote_models.length > 0
|
||||||
{providerModels.db_models.length === 0 ? (
|
? 'provided'
|
||||||
<div className='flex flex-col items-center justify-center gap-2 py-4'>
|
: 'custom'
|
||||||
|
}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
<TabsList className='grid w-full grid-cols-2'>
|
||||||
|
<TabsTrigger
|
||||||
|
value='provided'
|
||||||
|
className='text-xs sm:text-sm'
|
||||||
|
>
|
||||||
|
<span className='hidden sm:inline'>
|
||||||
|
Provided Models
|
||||||
|
</span>
|
||||||
|
<span className='sm:hidden'>Provided</span>
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
className='ml-1 text-xs sm:ml-2'
|
||||||
|
>
|
||||||
|
{providerModels.remote_models.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value='custom'
|
||||||
|
className='text-xs sm:text-sm'
|
||||||
|
>
|
||||||
|
<span className='hidden sm:inline'>
|
||||||
|
Custom Models
|
||||||
|
</span>
|
||||||
|
<span className='sm:hidden'>Custom</span>
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
className='ml-1 text-xs sm:ml-2'
|
||||||
|
>
|
||||||
|
{providerModels.db_models.length}
|
||||||
|
</Badge>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
value='custom'
|
||||||
|
className='mt-4 space-y-2'
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
{providerModels.db_models.length > 0 && (
|
||||||
<div className='text-muted-foreground text-sm'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
No models configured. Add custom models
|
Custom models override or extend the
|
||||||
to use this provider.
|
provider's catalog.
|
||||||
</div>
|
</div>
|
||||||
<Button
|
)}
|
||||||
variant='outline'
|
<Button
|
||||||
size='sm'
|
variant='outline'
|
||||||
onClick={() =>
|
size='sm'
|
||||||
handleAddModel(provider.id)
|
onClick={() =>
|
||||||
}
|
handleAddModel(provider.id)
|
||||||
>
|
}
|
||||||
<Plus className='mr-2 h-4 w-4' />
|
>
|
||||||
Add Custom Model
|
<Plus className='mr-2 h-4 w-4' />
|
||||||
</Button>
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{providerModels.db_models.length === 0 ? (
|
||||||
|
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||||
|
No custom models configured
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
@@ -1000,103 +1073,48 @@ export default function ProvidersPage() {
|
|||||||
>
|
>
|
||||||
<Pencil className='h-4 w-4' />
|
<Pencil className='h-4 w-4' />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='text-destructive hover:text-destructive h-8 w-8'
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteModel(
|
||||||
|
provider.id,
|
||||||
|
model.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
deleteModelMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TabsContent>
|
||||||
) : (
|
<TabsContent
|
||||||
// Has provided models - show tabs
|
value='provided'
|
||||||
<Tabs
|
className='mt-4 space-y-2'
|
||||||
defaultValue='provided'
|
|
||||||
className='w-full'
|
|
||||||
>
|
>
|
||||||
<TabsList className='grid w-full grid-cols-2'>
|
{providerModels.remote_models.length > 0 ? (
|
||||||
<TabsTrigger
|
<>
|
||||||
value='provided'
|
<div className='text-muted-foreground mb-3 text-sm'>
|
||||||
className='text-xs sm:text-sm'
|
Models automatically discovered from the
|
||||||
>
|
provider's catalog.
|
||||||
<span className='hidden sm:inline'>
|
|
||||||
Provided Models
|
|
||||||
</span>
|
|
||||||
<span className='sm:hidden'>
|
|
||||||
Provided
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant='secondary'
|
|
||||||
className='ml-1 text-xs sm:ml-2'
|
|
||||||
>
|
|
||||||
{providerModels.remote_models.length}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value='custom'
|
|
||||||
className='text-xs sm:text-sm'
|
|
||||||
>
|
|
||||||
<span className='hidden sm:inline'>
|
|
||||||
Custom Models
|
|
||||||
</span>
|
|
||||||
<span className='sm:hidden'>Custom</span>
|
|
||||||
<Badge
|
|
||||||
variant='secondary'
|
|
||||||
className='ml-1 text-xs sm:ml-2'
|
|
||||||
>
|
|
||||||
{providerModels.db_models.length}
|
|
||||||
</Badge>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent
|
|
||||||
value='custom'
|
|
||||||
className='mt-4 space-y-2'
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
{providerModels.db_models.length > 0 && (
|
|
||||||
<div className='text-muted-foreground text-sm'>
|
|
||||||
Custom models override or extend the
|
|
||||||
provider's catalog.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
onClick={() =>
|
|
||||||
handleAddModel(provider.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className='mr-2 h-4 w-4' />
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{providerModels.db_models.length === 0 ? (
|
|
||||||
<div className='text-muted-foreground py-4 text-center text-sm'>
|
|
||||||
No custom models configured
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
{providerModels.db_models.map(
|
{providerModels.remote_models.map(
|
||||||
(model) => (
|
(model) => (
|
||||||
<div
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
||||||
>
|
>
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2'>
|
<div className='truncate font-mono text-sm font-medium'>
|
||||||
<span className='truncate font-mono text-sm font-medium'>
|
{model.id}
|
||||||
{model.id}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
model.enabled
|
|
||||||
? 'default'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
className='w-fit text-xs'
|
|
||||||
>
|
|
||||||
{model.enabled
|
|
||||||
? 'Enabled'
|
|
||||||
: 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
||||||
{model.description ||
|
{model.description ||
|
||||||
@@ -1109,79 +1127,32 @@ export default function ProvidersPage() {
|
|||||||
tokens
|
tokens
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='outline'
|
||||||
size='icon'
|
size='sm'
|
||||||
className='h-8 w-8'
|
className='h-7 text-xs'
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleEditModel(
|
handleOverrideModel(
|
||||||
provider.id,
|
provider.id,
|
||||||
model
|
model
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Pencil className='h-4 w-4' />
|
<Plus className='mr-1 h-3 w-3' />
|
||||||
|
Override
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</TabsContent>
|
) : (
|
||||||
<TabsContent
|
<div className='text-muted-foreground py-4 text-center text-sm'>
|
||||||
value='provided'
|
No provided models available
|
||||||
className='mt-4 space-y-2'
|
|
||||||
>
|
|
||||||
{providerModels.remote_models.length >
|
|
||||||
0 && (
|
|
||||||
<div className='text-muted-foreground mb-3 text-sm'>
|
|
||||||
Models automatically discovered from the
|
|
||||||
provider's catalog.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{providerModels.remote_models.map(
|
|
||||||
(model) => (
|
|
||||||
<div
|
|
||||||
key={model.id}
|
|
||||||
className='hover:bg-accent flex flex-col gap-2 rounded-lg border p-3 transition-colors sm:flex-row sm:items-center sm:justify-between'
|
|
||||||
>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<div className='truncate font-mono text-sm font-medium'>
|
|
||||||
{model.id}
|
|
||||||
</div>
|
|
||||||
<div className='text-muted-foreground mt-1 text-xs break-words'>
|
|
||||||
{model.description ||
|
|
||||||
model.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div className='text-muted-foreground text-xs whitespace-nowrap'>
|
|
||||||
{model.context_length?.toLocaleString()}{' '}
|
|
||||||
tokens
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
className='h-7 text-xs'
|
|
||||||
onClick={() =>
|
|
||||||
handleOverrideModel(
|
|
||||||
provider.id,
|
|
||||||
model
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className='mr-1 h-3 w-3' />
|
|
||||||
Override
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
)}
|
||||||
</Tabs>
|
</TabsContent>
|
||||||
)
|
</Tabs>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -248,7 +248,9 @@ export function AddProviderModelDialog({
|
|||||||
const pricing = model.pricing as Record<string, number>;
|
const pricing = model.pricing as Record<string, number>;
|
||||||
const topProvider = model.top_provider as Record<string, unknown> | null;
|
const topProvider = model.top_provider as Record<string, unknown> | null;
|
||||||
|
|
||||||
form.setValue('id', model.id);
|
if (!isOverride) {
|
||||||
|
form.setValue('id', model.id);
|
||||||
|
}
|
||||||
form.setValue('name', model.name);
|
form.setValue('name', model.name);
|
||||||
form.setValue('description', model.description || '');
|
form.setValue('description', model.description || '');
|
||||||
form.setValue('context_length', model.context_length);
|
form.setValue('context_length', model.context_length);
|
||||||
@@ -425,7 +427,7 @@ export function AddProviderModelDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>{description}</DialogDescription>
|
<DialogDescription>{description}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!isEdit && !isOverride && (
|
{!isEdit && (
|
||||||
<div className='bg-muted/30 rounded-md border p-3'>
|
<div className='bg-muted/30 rounded-md border p-3'>
|
||||||
<div className='mb-2 text-sm font-medium'>Presets</div>
|
<div className='mb-2 text-sm font-medium'>Presets</div>
|
||||||
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
|
<div className='grid gap-2 sm:grid-cols-3 sm:items-start'>
|
||||||
@@ -488,8 +490,9 @@ export function AddProviderModelDialog({
|
|||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground mt-1 text-xs'>
|
<div className='text-muted-foreground mt-1 text-xs'>
|
||||||
Prefill fields from a preset model definition, then adjust as
|
{isOverride
|
||||||
needed.
|
? 'Apply pricing and settings from a preset model (keeping the model ID unchanged).'
|
||||||
|
: 'Prefill fields from a preset model definition, then adjust as needed.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -805,45 +808,6 @@ export function AddProviderModelDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='max_prompt_cost'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Max Prompt Cost</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type='number' step='0.0001' {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='max_completion_cost'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Max Completion Cost</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type='number' step='0.0001' {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name='max_cost'
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Max Total Cost</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type='number' step='0.0001' {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { WalletService } from '@/lib/api/services/wallet';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Key, Copy, Check, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface ChildKeyCreatorProps {
|
||||||
|
baseUrl?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
onApiKeyChange?: (apiKey: string) => void;
|
||||||
|
costPerKeyMsats?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChildKeyCreator({
|
||||||
|
baseUrl,
|
||||||
|
apiKey: propApiKey,
|
||||||
|
onApiKeyChange,
|
||||||
|
costPerKeyMsats,
|
||||||
|
}: ChildKeyCreatorProps) {
|
||||||
|
const [internalApiKey, setInternalApiKey] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [count, setCount] = useState(1);
|
||||||
|
const [newKeys, setNewKeys] = useState<string[]>([]);
|
||||||
|
const [resultInfo, setResultInfo] = useState<{
|
||||||
|
cost_msats: number;
|
||||||
|
parent_balance: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const activeApiKey = propApiKey ?? internalApiKey;
|
||||||
|
|
||||||
|
const handleApiKeyChange = (val: string) => {
|
||||||
|
setInternalApiKey(val);
|
||||||
|
onApiKeyChange?.(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateKey = async () => {
|
||||||
|
if (!activeApiKey && baseUrl) {
|
||||||
|
toast.error('Please provide a Parent API key first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedCount = Math.max(1, Math.min(50, Number(count)));
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await WalletService.createChildKey(
|
||||||
|
baseUrl,
|
||||||
|
activeApiKey,
|
||||||
|
requestedCount
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Created child keys:', result);
|
||||||
|
|
||||||
|
if (result.api_keys && result.api_keys.length > 0) {
|
||||||
|
setNewKeys(result.api_keys);
|
||||||
|
} else {
|
||||||
|
throw new Error('No API keys returned from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
setResultInfo({
|
||||||
|
cost_msats: result.cost_msats,
|
||||||
|
parent_balance: result.parent_balance,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(
|
||||||
|
`${requestedCount} child API key${
|
||||||
|
requestedCount > 1 ? 's' : ''
|
||||||
|
} created successfully`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create child key:', error);
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : 'Failed to create child key'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (key: string) => {
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
setCopiedKey(key);
|
||||||
|
toast.success('API key copied to clipboard');
|
||||||
|
setTimeout(() => setCopiedKey(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAllToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(newKeys.join('\n'));
|
||||||
|
toast.success('All API keys copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<CardTitle>Create Child API Key</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Generate secondary API keys that share your account balance.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
{costPerKeyMsats !== undefined && (
|
||||||
|
<div className='text-right'>
|
||||||
|
<p className='text-muted-foreground text-[0.65rem] tracking-wide uppercase'>
|
||||||
|
Unit Cost
|
||||||
|
</p>
|
||||||
|
<p className='text-primary text-sm font-bold'>
|
||||||
|
{costPerKeyMsats / 1000} sats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{baseUrl && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
Parent API Key
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={activeApiKey}
|
||||||
|
onChange={(e) => handleApiKeyChange(e.target.value)}
|
||||||
|
placeholder='sk-...'
|
||||||
|
className='font-mono text-sm'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between'>
|
||||||
|
<div className='flex-1 space-y-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
Number of keys
|
||||||
|
</label>
|
||||||
|
{costPerKeyMsats && (
|
||||||
|
<span className='text-muted-foreground text-[10px]'>
|
||||||
|
Cost: {costPerKeyMsats * count} mSats
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={count}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value);
|
||||||
|
if (!isNaN(val)) {
|
||||||
|
setCount(Math.max(1, Math.min(50, val)));
|
||||||
|
} else {
|
||||||
|
setCount(1);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='w-full sm:w-24'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateKey}
|
||||||
|
disabled={loading || (!!baseUrl && !activeApiKey)}
|
||||||
|
className='w-full sm:w-auto'
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Key className='mr-2 h-4 w-4' />
|
||||||
|
Generate {count > 1 ? `${count} Keys` : 'Key'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
Each key creation has a small one-time fee.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{newKeys.length > 0 && (
|
||||||
|
<div className='mt-6 space-y-4'>
|
||||||
|
<Alert className='border-green-200 bg-green-50 dark:border-green-900/20 dark:bg-green-900/10'>
|
||||||
|
<AlertTitle className='text-green-800 dark:text-green-400'>
|
||||||
|
{newKeys.length} New API Key{newKeys.length > 1 ? 's' : ''}{' '}
|
||||||
|
Generated
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className='text-green-700 dark:text-green-500'>
|
||||||
|
Copy {newKeys.length > 1 ? 'these keys' : 'this key'} now.
|
||||||
|
You won't be able to see them again.
|
||||||
|
{resultInfo && (
|
||||||
|
<div className='mt-2 font-medium opacity-80'>
|
||||||
|
Total Cost: {resultInfo.cost_msats / 1000} sats | New
|
||||||
|
Balance: {resultInfo.parent_balance / 1000} sats
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='text-muted-foreground text-xs font-medium uppercase'>
|
||||||
|
Generated Keys ({newKeys.length})
|
||||||
|
</span>
|
||||||
|
{newKeys.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='h-7 text-[10px] uppercase'
|
||||||
|
onClick={copyAllToClipboard}
|
||||||
|
>
|
||||||
|
<Copy className='mr-1 h-3 w-3' />
|
||||||
|
Copy All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
{newKeys.map((key, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='group relative flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<code className='bg-muted/50 flex-1 rounded border p-2.5 font-mono text-[10px] break-all sm:text-xs'>
|
||||||
|
{key}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
size='icon'
|
||||||
|
variant='ghost'
|
||||||
|
className='h-8 w-8 shrink-0'
|
||||||
|
onClick={() => copyToClipboard(key)}
|
||||||
|
>
|
||||||
|
{copiedKey === key ? (
|
||||||
|
<Check className='h-3.5 w-3.5 text-green-500' />
|
||||||
|
) : (
|
||||||
|
<Copy className='h-3.5 w-3.5 opacity-50 group-hover:opacity-100' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{newKeys.length > 3 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-muted-foreground text-[0.7rem] tracking-wider uppercase'>
|
||||||
|
Bulk Export (All Keys)
|
||||||
|
</label>
|
||||||
|
<div className='relative'>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={newKeys.join('\n')}
|
||||||
|
rows={Math.min(newKeys.length, 6)}
|
||||||
|
className='bg-muted/30 w-full rounded-md border p-3 font-mono text-[10px] focus:outline-none'
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='secondary'
|
||||||
|
className='absolute right-2 bottom-2 h-7 text-[10px]'
|
||||||
|
onClick={copyAllToClipboard}
|
||||||
|
>
|
||||||
|
Copy Bulk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { ConfigurationService } from '@/lib/api/services/configuration';
|
|||||||
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
|
import { CashuPaymentWorkflow } from './cashu-payment-workflow';
|
||||||
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
import { LightningPaymentWorkflow } from './lightning-payment-workflow';
|
||||||
import { ApiKeyManager } from './api-key-manager';
|
import { ApiKeyManager } from './api-key-manager';
|
||||||
|
import { ChildKeyCreator } from '@/components/child-key-creator';
|
||||||
|
|
||||||
type NodeInfo = {
|
type NodeInfo = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -23,6 +24,7 @@ type NodeInfo = {
|
|||||||
mints: string[];
|
mints: string[];
|
||||||
http_url?: string | null;
|
http_url?: string | null;
|
||||||
onion_url?: string | null;
|
onion_url?: string | null;
|
||||||
|
child_key_cost_msats?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type WalletSnapshot = {
|
type WalletSnapshot = {
|
||||||
@@ -362,10 +364,11 @@ export function CheatSheet(): JSX.Element {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Tabs defaultValue='cashu' className='w-full'>
|
<Tabs defaultValue='cashu' className='w-full'>
|
||||||
<TabsList className='grid w-full grid-cols-3'>
|
<TabsList className='grid w-full grid-cols-4'>
|
||||||
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
|
<TabsTrigger value='cashu'>Cashu Payments</TabsTrigger>
|
||||||
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
|
<TabsTrigger value='lightning'>Lightning Payments</TabsTrigger>
|
||||||
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
<TabsTrigger value='manage'>Manage Keys</TabsTrigger>
|
||||||
|
<TabsTrigger value='child-keys'>Child Keys</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='cashu' className='space-y-4'>
|
<TabsContent value='cashu' className='space-y-4'>
|
||||||
@@ -422,6 +425,15 @@ export function CheatSheet(): JSX.Element {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value='child-keys' className='space-y-4'>
|
||||||
|
<ChildKeyCreator
|
||||||
|
baseUrl={normalizedBaseUrl}
|
||||||
|
apiKey={apiKeyInput}
|
||||||
|
onApiKeyChange={handleApiKeyChanged}
|
||||||
|
costPerKeyMsats={nodeInfo?.child_key_cost_msats}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export function TemporaryBalances({
|
|||||||
let totalRequests = 0;
|
let totalRequests = 0;
|
||||||
|
|
||||||
balances.forEach((balance) => {
|
balances.forEach((balance) => {
|
||||||
totalBalance += balance.balance || 0;
|
// Only count parents for total balance to avoid double counting
|
||||||
|
// since child keys use parent balance
|
||||||
|
if (!balance.parent_key_hash) {
|
||||||
|
totalBalance += balance.balance || 0;
|
||||||
|
}
|
||||||
totalSpent += balance.total_spent || 0;
|
totalSpent += balance.total_spent || 0;
|
||||||
totalRequests += balance.total_requests || 0;
|
totalRequests += balance.total_requests || 0;
|
||||||
});
|
});
|
||||||
@@ -72,6 +76,34 @@ export function TemporaryBalances({
|
|||||||
? calculateTotals(data)
|
? calculateTotals(data)
|
||||||
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
: { totalBalance: 0, totalSpent: 0, totalRequests: 0 };
|
||||||
|
|
||||||
|
// Group parents and children
|
||||||
|
const hierarchicalData = (() => {
|
||||||
|
if (!data) return [];
|
||||||
|
|
||||||
|
const parents = filteredData.filter((item) => !item.parent_key_hash);
|
||||||
|
const result: (TemporaryBalance & { isChild?: boolean })[] = [];
|
||||||
|
|
||||||
|
parents.forEach((parent) => {
|
||||||
|
result.push(parent);
|
||||||
|
const children = data.filter(
|
||||||
|
(item) => item.parent_key_hash === parent.hashed_key
|
||||||
|
);
|
||||||
|
children.forEach((child) => {
|
||||||
|
result.push({ ...child, isChild: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add children whose parents didn't match the search or aren't in the list
|
||||||
|
const orphans = filteredData.filter(
|
||||||
|
(item) =>
|
||||||
|
item.parent_key_hash &&
|
||||||
|
!result.some((r) => r.hashed_key === item.hashed_key)
|
||||||
|
);
|
||||||
|
result.push(...orphans.map((o) => ({ ...o, isChild: true })));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card className='h-full w-full shadow-sm'>
|
<Card className='h-full w-full shadow-sm'>
|
||||||
@@ -182,22 +214,37 @@ export function TemporaryBalances({
|
|||||||
<div className='text-right'>Expiry Time</div>
|
<div className='text-right'>Expiry Time</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredData.length > 0 ? (
|
{hierarchicalData.length > 0 ? (
|
||||||
filteredData.map((balance, index) => (
|
hierarchicalData.map((balance, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
'hover:bg-muted/50 border-t p-3 text-sm transition-colors',
|
'hover:bg-muted/50 border-t p-3 text-sm transition-colors',
|
||||||
balance.balance === 0 && 'opacity-60'
|
balance.balance === 0 &&
|
||||||
|
!balance.isChild &&
|
||||||
|
'opacity-60',
|
||||||
|
balance.isChild &&
|
||||||
|
'ml-4 border-l-2 border-l-blue-200 bg-blue-50/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Desktop Layout */}
|
{/* Desktop Layout */}
|
||||||
<div className='hidden grid-cols-6 gap-2 md:grid'>
|
<div className='hidden grid-cols-6 gap-2 md:grid'>
|
||||||
<div className='max-w-32 truncate font-mono text-xs break-all'>
|
<div className='flex max-w-48 items-center gap-2 truncate font-mono text-xs break-all'>
|
||||||
|
{balance.isChild && (
|
||||||
|
<span className='rounded bg-blue-100 px-1 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
|
||||||
|
Child
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{balance.hashed_key}
|
{balance.hashed_key}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-right font-mono'>
|
<div className='text-right font-mono'>
|
||||||
{formatBalance(balance.balance)}
|
{balance.isChild ? (
|
||||||
|
<span className='text-muted-foreground italic'>
|
||||||
|
(Parent)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatBalance(balance.balance)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-right font-mono'>
|
<div className='text-right font-mono'>
|
||||||
{formatBalance(balance.total_spent)}
|
{formatBalance(balance.total_spent)}
|
||||||
@@ -226,13 +273,20 @@ export function TemporaryBalances({
|
|||||||
|
|
||||||
{/* Mobile Layout */}
|
{/* Mobile Layout */}
|
||||||
<div className='space-y-3 md:hidden'>
|
<div className='space-y-3 md:hidden'>
|
||||||
<div className='space-y-1'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='text-muted-foreground text-xs font-medium'>
|
<div className='space-y-1'>
|
||||||
Key
|
<span className='text-muted-foreground text-xs font-medium'>
|
||||||
</span>
|
{balance.isChild ? 'Child Key' : 'Key'}
|
||||||
<div className='font-mono text-xs break-all'>
|
</span>
|
||||||
{balance.hashed_key}
|
<div className='font-mono text-xs break-all'>
|
||||||
|
{balance.hashed_key}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{balance.isChild && (
|
||||||
|
<span className='rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-bold text-blue-700 uppercase'>
|
||||||
|
Child
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-3'>
|
<div className='grid grid-cols-2 gap-3'>
|
||||||
@@ -241,7 +295,13 @@ export function TemporaryBalances({
|
|||||||
Balance
|
Balance
|
||||||
</div>
|
</div>
|
||||||
<div className='truncate font-mono text-sm'>
|
<div className='truncate font-mono text-sm'>
|
||||||
{formatBalance(balance.balance)}
|
{balance.isChild ? (
|
||||||
|
<span className='text-muted-foreground text-xs italic'>
|
||||||
|
(Uses Parent)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
formatBalance(balance.balance)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-1'>
|
<div className='space-y-1'>
|
||||||
|
|||||||
@@ -139,18 +139,26 @@ export class AdminService {
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
if (!pricing) return pricing;
|
if (!pricing) return pricing;
|
||||||
const result = { ...pricing };
|
const result = { ...pricing };
|
||||||
if (typeof result.prompt === 'number') {
|
|
||||||
result.prompt = result.prompt * 1000000;
|
// Only prompt and completion are per-token and need scaling to per-1M
|
||||||
}
|
const convertField = (field: string) => {
|
||||||
if (typeof result.completion === 'number') {
|
const val = result[field];
|
||||||
result.completion = result.completion * 1000000;
|
if (val !== undefined && val !== null) {
|
||||||
}
|
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
|
||||||
if (typeof result.request === 'number') {
|
if (!isNaN(num)) {
|
||||||
result.request = result.request * 1000000;
|
// Multiply by 1M and round to avoid floating point artifacts (e.g. 0.40399999999999997)
|
||||||
}
|
// 9 decimals is plenty for USD/1M tokens (0.000000001)
|
||||||
if (typeof result.image === 'number') {
|
result[field] = parseFloat((num * 1000000).toFixed(9));
|
||||||
result.image = result.image * 1000000;
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
convertField('prompt');
|
||||||
|
convertField('completion');
|
||||||
|
|
||||||
|
// Other fields (request, image, etc.) are already flat fees (per item)
|
||||||
|
// so we do NOT scale them.
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,18 +167,23 @@ export class AdminService {
|
|||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
if (!pricing) return pricing;
|
if (!pricing) return pricing;
|
||||||
const result = { ...pricing };
|
const result = { ...pricing };
|
||||||
if (typeof result.prompt === 'number') {
|
|
||||||
result.prompt = result.prompt / 1000000;
|
// Only prompt and completion are per-1M in UI and need scaling down to per-token
|
||||||
}
|
const convertField = (field: string) => {
|
||||||
if (typeof result.completion === 'number') {
|
const val = result[field];
|
||||||
result.completion = result.completion / 1000000;
|
if (val !== undefined && val !== null) {
|
||||||
}
|
const num = typeof val === 'string' ? parseFloat(val) : (val as number);
|
||||||
if (typeof result.request === 'number') {
|
if (!isNaN(num)) {
|
||||||
result.request = result.request / 1000000;
|
result[field] = num / 1000000;
|
||||||
}
|
}
|
||||||
if (typeof result.image === 'number') {
|
}
|
||||||
result.image = result.image / 1000000;
|
};
|
||||||
}
|
|
||||||
|
convertField('prompt');
|
||||||
|
convertField('completion');
|
||||||
|
|
||||||
|
// Other fields stay as flat fees
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +304,19 @@ export class AdminService {
|
|||||||
const data = await apiClient.get<ProviderModels>(
|
const data = await apiClient.get<ProviderModels>(
|
||||||
`/admin/api/upstream-providers/${providerId}/models`
|
`/admin/api/upstream-providers/${providerId}/models`
|
||||||
);
|
);
|
||||||
return data;
|
|
||||||
|
// Convert pricing for all models in the list so the UI receives "per 1M tokens" values
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
db_models: data.db_models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
pricing: this.convertPricingToPerMillionTokens(m.pricing),
|
||||||
|
})),
|
||||||
|
remote_models: data.remote_models.map((m) => ({
|
||||||
|
...m,
|
||||||
|
pricing: this.convertPricingToPerMillionTokens(m.pricing),
|
||||||
|
})),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createProviderModel(
|
static async createProviderModel(
|
||||||
@@ -498,8 +523,8 @@ export class AdminService {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const pricing = {
|
const pricing = {
|
||||||
prompt: (data.input_cost as number) / 1000000,
|
prompt: data.input_cost as number,
|
||||||
completion: (data.output_cost as number) / 1000000,
|
completion: data.output_cost as number,
|
||||||
request: (data.min_cost_per_request as number) || 0,
|
request: (data.min_cost_per_request as number) || 0,
|
||||||
image: 0,
|
image: 0,
|
||||||
web_search: 0,
|
web_search: 0,
|
||||||
@@ -553,8 +578,8 @@ export class AdminService {
|
|||||||
const existingModel = await this.getModel(modelId, providerId);
|
const existingModel = await this.getModel(modelId, providerId);
|
||||||
|
|
||||||
const pricing = {
|
const pricing = {
|
||||||
prompt: (data.input_cost as number) / 1000000,
|
prompt: data.input_cost as number,
|
||||||
completion: (data.output_cost as number) / 1000000,
|
completion: data.output_cost as number,
|
||||||
request: (data.min_cost_per_request as number) || 0,
|
request: (data.min_cost_per_request as number) || 0,
|
||||||
image: 0,
|
image: 0,
|
||||||
web_search: 0,
|
web_search: 0,
|
||||||
@@ -926,6 +951,7 @@ export const TemporaryBalanceSchema = z.object({
|
|||||||
total_requests: z.number(),
|
total_requests: z.number(),
|
||||||
refund_address: z.string().nullable(),
|
refund_address: z.string().nullable(),
|
||||||
key_expiry_time: z.number().nullable(),
|
key_expiry_time: z.number().nullable(),
|
||||||
|
parent_key_hash: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
export type TemporaryBalance = z.infer<typeof TemporaryBalanceSchema>;
|
||||||
|
|||||||
@@ -29,6 +29,28 @@ export type RedeemTokenResponse = z.infer<typeof RedeemTokenResponseSchema>;
|
|||||||
export type SendTokenRequest = z.infer<typeof SendTokenRequestSchema>;
|
export type SendTokenRequest = z.infer<typeof SendTokenRequestSchema>;
|
||||||
export type SendTokenResponse = z.infer<typeof SendTokenResponseSchema>;
|
export type SendTokenResponse = z.infer<typeof SendTokenResponseSchema>;
|
||||||
|
|
||||||
|
export interface BalanceDetail {
|
||||||
|
mint_url: string;
|
||||||
|
unit: string;
|
||||||
|
wallet_balance: number;
|
||||||
|
user_balance: number;
|
||||||
|
owner_balance: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawResponse {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChildKeyResponse {
|
||||||
|
api_keys: string[];
|
||||||
|
count: number;
|
||||||
|
cost_msats: number;
|
||||||
|
cost_sats: number;
|
||||||
|
parent_balance: number;
|
||||||
|
parent_balance_sats: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class WalletService {
|
export class WalletService {
|
||||||
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
|
static async redeemToken(token: string): Promise<RedeemTokenResponse> {
|
||||||
try {
|
try {
|
||||||
@@ -108,17 +130,38 @@ export class WalletService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export interface BalanceDetail {
|
static async createChildKey(
|
||||||
mint_url: string;
|
baseUrl?: string,
|
||||||
unit: string;
|
apiKey?: string,
|
||||||
wallet_balance: number;
|
count: number = 1
|
||||||
user_balance: number;
|
): Promise<CreateChildKeyResponse> {
|
||||||
owner_balance: number;
|
try {
|
||||||
error?: string;
|
if (baseUrl && apiKey) {
|
||||||
}
|
const response = await fetch(`${baseUrl}/v1/balance/child-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ count }),
|
||||||
|
});
|
||||||
|
|
||||||
export interface WithdrawResponse {
|
if (!response.ok) {
|
||||||
token: string;
|
const errorText = await response.text();
|
||||||
|
throw new Error(errorText || 'Failed to create child key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as CreateChildKeyResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await apiClient.post<CreateChildKeyResponse>(
|
||||||
|
'/v1/balance/child-key',
|
||||||
|
{ count }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating child key:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user