lambda-erp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api/__init__.py +0 -0
- api/attachments.py +229 -0
- api/auth.py +511 -0
- api/bootstrap.py +498 -0
- api/chat.py +2764 -0
- api/demo_limits.py +400 -0
- api/deps.py +7 -0
- api/errors.py +56 -0
- api/main.py +182 -0
- api/pdf.py +151 -0
- api/providers.py +116 -0
- api/routers/__init__.py +0 -0
- api/routers/accounting.py +63 -0
- api/routers/admin.py +122 -0
- api/routers/analytics.py +1009 -0
- api/routers/bank_reconciliation.py +31 -0
- api/routers/documents.py +100 -0
- api/routers/masters.py +396 -0
- api/routers/reports.py +735 -0
- api/routers/setup.py +387 -0
- api/services.py +372 -0
- api/templates/document.html +197 -0
- lambda_erp/__init__.py +3 -0
- lambda_erp/accounting/__init__.py +0 -0
- lambda_erp/accounting/bank_transaction.py +76 -0
- lambda_erp/accounting/budget.py +117 -0
- lambda_erp/accounting/chart_of_accounts.py +183 -0
- lambda_erp/accounting/general_ledger.py +362 -0
- lambda_erp/accounting/journal_entry.py +235 -0
- lambda_erp/accounting/payment_entry.py +515 -0
- lambda_erp/accounting/pos_invoice.py +342 -0
- lambda_erp/accounting/purchase_invoice.py +504 -0
- lambda_erp/accounting/revaluation.py +172 -0
- lambda_erp/accounting/sales_invoice.py +523 -0
- lambda_erp/accounting/subscription.py +132 -0
- lambda_erp/buying/__init__.py +0 -0
- lambda_erp/buying/purchase_order.py +165 -0
- lambda_erp/controllers/__init__.py +0 -0
- lambda_erp/controllers/currency.py +52 -0
- lambda_erp/controllers/defaults.py +51 -0
- lambda_erp/controllers/pricing_rule.py +103 -0
- lambda_erp/controllers/taxes_and_totals.py +369 -0
- lambda_erp/database.py +1543 -0
- lambda_erp/exceptions.py +37 -0
- lambda_erp/hooks.py +37 -0
- lambda_erp/model.py +462 -0
- lambda_erp/selling/__init__.py +0 -0
- lambda_erp/selling/quotation.py +263 -0
- lambda_erp/selling/sales_order.py +214 -0
- lambda_erp/simulation.py +704 -0
- lambda_erp/stock/__init__.py +0 -0
- lambda_erp/stock/delivery_note.py +254 -0
- lambda_erp/stock/purchase_receipt.py +356 -0
- lambda_erp/stock/stock_entry.py +330 -0
- lambda_erp/stock/stock_ledger.py +337 -0
- lambda_erp/utils.py +167 -0
- lambda_erp-0.1.0.dist-info/METADATA +454 -0
- lambda_erp-0.1.0.dist-info/RECORD +60 -0
- lambda_erp-0.1.0.dist-info/WHEEL +4 -0
- lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
api/pdf.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""PDF generation for ERP documents."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from jinja2 import Environment, FileSystemLoader
|
|
5
|
+
from weasyprint import HTML
|
|
6
|
+
from lambda_erp.database import get_db
|
|
7
|
+
from api.services import load_document
|
|
8
|
+
|
|
9
|
+
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
|
10
|
+
_jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
|
|
11
|
+
|
|
12
|
+
# Document type → (title, party_field, party_name_field, party_doctype, party_label)
|
|
13
|
+
DOC_CONFIG = {
|
|
14
|
+
"Quotation": ("Quotation", "customer", "customer_name", "Customer", "Customer"),
|
|
15
|
+
"Sales Order": ("Sales Order", "customer", "customer_name", "Customer", "Customer"),
|
|
16
|
+
"Sales Invoice": ("Sales Invoice", "customer", "customer_name", "Customer", "Customer"),
|
|
17
|
+
"Purchase Order": ("Purchase Order", "supplier", "supplier_name", "Supplier", "Supplier"),
|
|
18
|
+
"Purchase Invoice": ("Purchase Invoice", "supplier", "supplier_name", "Supplier", "Supplier"),
|
|
19
|
+
"Delivery Note": ("Delivery Note", "customer", "customer_name", "Customer", "Customer"),
|
|
20
|
+
"Purchase Receipt": ("Purchase Receipt", "supplier", "supplier_name", "Supplier", "Supplier"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
SHOW_WAREHOUSE = {"Delivery Note", "Purchase Receipt"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _get_dict(row):
|
|
27
|
+
"""Convert a database row to a plain dict."""
|
|
28
|
+
if row is None:
|
|
29
|
+
return {}
|
|
30
|
+
if isinstance(row, dict):
|
|
31
|
+
return row
|
|
32
|
+
return dict(row) if row else {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_pdf(doctype_slug: str, name: str) -> bytes:
|
|
36
|
+
"""Generate a PDF for a document and return raw bytes."""
|
|
37
|
+
doc = load_document(doctype_slug, name)
|
|
38
|
+
db = get_db()
|
|
39
|
+
|
|
40
|
+
# Resolve doctype display name
|
|
41
|
+
doctype = doctype_slug.replace("-", " ").title()
|
|
42
|
+
# Fix multi-word: "Sales Invoice" not "Sales-Invoice"
|
|
43
|
+
for dt in DOC_CONFIG:
|
|
44
|
+
if dt.lower().replace(" ", "-") == doctype_slug:
|
|
45
|
+
doctype = dt
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
config = DOC_CONFIG.get(doctype)
|
|
49
|
+
if not config:
|
|
50
|
+
# Fallback for unknown types
|
|
51
|
+
config = (doctype, None, None, None, "Party")
|
|
52
|
+
|
|
53
|
+
title, party_field, party_name_field, party_doctype, party_label = config
|
|
54
|
+
|
|
55
|
+
# Credit note / debit note titles
|
|
56
|
+
if doc.get("is_return"):
|
|
57
|
+
if doctype == "Sales Invoice":
|
|
58
|
+
title = "Credit Note"
|
|
59
|
+
elif doctype == "Purchase Invoice":
|
|
60
|
+
title = "Debit Note"
|
|
61
|
+
else:
|
|
62
|
+
title = f"{title} (Return)"
|
|
63
|
+
|
|
64
|
+
# Party info
|
|
65
|
+
party_name = doc.get(party_name_field or "", "") or doc.get(party_field or "", "")
|
|
66
|
+
party_info = {}
|
|
67
|
+
if party_field and party_doctype and doc.get(party_field):
|
|
68
|
+
fields = ["email", "phone", "address", "city", "zip_code", "country", "tax_id"]
|
|
69
|
+
if party_name_field:
|
|
70
|
+
fields = [party_name_field, *fields]
|
|
71
|
+
row = db.get_value(party_doctype, doc[party_field], fields)
|
|
72
|
+
if row:
|
|
73
|
+
party_info = _get_dict(row)
|
|
74
|
+
# Show the party's CURRENT name from the master, so a later
|
|
75
|
+
# correction (e.g. fixing a typo) appears on the PDF — consistent
|
|
76
|
+
# with the address fields, which are already looked up live. The
|
|
77
|
+
# name stored on the document is only a fallback for when the
|
|
78
|
+
# master record no longer exists.
|
|
79
|
+
live_name = party_info.get(party_name_field) if party_name_field else None
|
|
80
|
+
if live_name:
|
|
81
|
+
party_name = live_name
|
|
82
|
+
|
|
83
|
+
# Company info
|
|
84
|
+
company_id = doc.get("company", "")
|
|
85
|
+
company_name = company_id # fallback to the id if the master is gone
|
|
86
|
+
company_info = {}
|
|
87
|
+
if company_id:
|
|
88
|
+
row = db.get_value("Company", company_id,
|
|
89
|
+
["company_name", "email", "phone", "address", "city", "zip_code", "country", "tax_id"])
|
|
90
|
+
if row:
|
|
91
|
+
company_info = _get_dict(row)
|
|
92
|
+
# Show the company's CURRENT display name from the master.
|
|
93
|
+
company_name = company_info.get("company_name") or company_id
|
|
94
|
+
|
|
95
|
+
# Currency
|
|
96
|
+
currency = doc.get("currency", "USD") or "USD"
|
|
97
|
+
|
|
98
|
+
# Meta fields (varies by doc type)
|
|
99
|
+
meta_fields = []
|
|
100
|
+
if doc.get("posting_date"):
|
|
101
|
+
meta_fields.append({"label": "Date", "value": doc["posting_date"]})
|
|
102
|
+
elif doc.get("transaction_date"):
|
|
103
|
+
meta_fields.append({"label": "Date", "value": doc["transaction_date"]})
|
|
104
|
+
if doc.get("due_date"):
|
|
105
|
+
meta_fields.append({"label": "Due Date", "value": doc["due_date"]})
|
|
106
|
+
if doc.get("valid_till"):
|
|
107
|
+
meta_fields.append({"label": "Valid Till", "value": doc["valid_till"]})
|
|
108
|
+
if doc.get("delivery_date"):
|
|
109
|
+
meta_fields.append({"label": "Delivery Date", "value": doc["delivery_date"]})
|
|
110
|
+
if doc.get("return_against"):
|
|
111
|
+
meta_fields.append({"label": "Return Against", "value": doc["return_against"]})
|
|
112
|
+
if doc.get("status"):
|
|
113
|
+
meta_fields.append({"label": "Status", "value": doc["status"]})
|
|
114
|
+
|
|
115
|
+
# Items — refresh each line's item_name from the Item master so a later
|
|
116
|
+
# correction to an item's name appears on the PDF, consistent with the
|
|
117
|
+
# party/company names. Quantities, rates, and amounts stay as transacted.
|
|
118
|
+
# Falls back to the name stored on the line if the master is gone.
|
|
119
|
+
items = doc.get("items", [])
|
|
120
|
+
for item in items:
|
|
121
|
+
code = item.get("item_code")
|
|
122
|
+
if code:
|
|
123
|
+
live_item_name = db.get_value("Item", code, "item_name")
|
|
124
|
+
if live_item_name:
|
|
125
|
+
item["item_name"] = live_item_name
|
|
126
|
+
|
|
127
|
+
taxes = doc.get("taxes", [])
|
|
128
|
+
|
|
129
|
+
# Page size setting
|
|
130
|
+
page_size_row = db.sql('SELECT value FROM "Settings" WHERE key = "pdf_page_size"')
|
|
131
|
+
page_size = page_size_row[0]["value"] if page_size_row else "A4"
|
|
132
|
+
|
|
133
|
+
# Render
|
|
134
|
+
template = _jinja_env.get_template("document.html")
|
|
135
|
+
html_str = template.render(
|
|
136
|
+
doc=doc,
|
|
137
|
+
title=title,
|
|
138
|
+
company_name=company_name,
|
|
139
|
+
company_info=company_info,
|
|
140
|
+
party_label=party_label,
|
|
141
|
+
party_name=party_name,
|
|
142
|
+
party_info=party_info,
|
|
143
|
+
currency=currency,
|
|
144
|
+
meta_fields=meta_fields,
|
|
145
|
+
items=items,
|
|
146
|
+
taxes=taxes,
|
|
147
|
+
show_warehouse=doctype in SHOW_WAREHOUSE,
|
|
148
|
+
page_size=page_size,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return HTML(string=html_str).write_pdf()
|
api/providers.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Pricing data for external LLM APIs the chat orchestrator uses.
|
|
2
|
+
|
|
3
|
+
Mirrors the shape of `lambda-web/backend/providers.py`. Only the models
|
|
4
|
+
this app actually calls are listed — refresh this dict when model
|
|
5
|
+
choices change. Prices are USD per 1M tokens.
|
|
6
|
+
|
|
7
|
+
Consumers import `cost_of_openai_call` / `cost_of_anthropic_call` to
|
|
8
|
+
turn an SDK `response.usage` object into a USD amount.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Pricing dicts
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
OPENAI_PRICING: dict[str, dict[str, Any]] = {
|
|
21
|
+
"gpt-5.4": {
|
|
22
|
+
"tiered": True,
|
|
23
|
+
"tiers": [
|
|
24
|
+
{"max_input_tokens": 272_000, "input": 2.50, "cached_input": 0.25, "output": 15.00},
|
|
25
|
+
{"max_input_tokens": None, "input": 5.00, "cached_input": 0.50, "output": 22.50},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
"gpt-4.1-nano": {"input": 0.10, "cached_input": 0.025, "output": 0.40},
|
|
29
|
+
"gpt-4.1-mini": {"input": 0.40, "cached_input": 0.10, "output": 1.60},
|
|
30
|
+
"gpt-4.1": {"input": 2.00, "cached_input": 0.50, "output": 8.00},
|
|
31
|
+
"gpt-5": {"input": 1.25, "cached_input": 0.125, "output": 10.00},
|
|
32
|
+
"gpt-5-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.00},
|
|
33
|
+
"gpt-5-nano": {"input": 0.05, "cached_input": 0.005, "output": 0.40},
|
|
34
|
+
"gpt-4o": {"input": 2.50, "cached_input": 1.25, "output": 10.00},
|
|
35
|
+
"gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ANTHROPIC_PRICING: dict[str, dict[str, Any]] = {
|
|
39
|
+
"claude-opus-4-7": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
|
|
40
|
+
"claude-opus-4-6": {"input": 5.00, "output": 25.00, "cache_write": 6.25, "cache_read": 0.50},
|
|
41
|
+
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
|
|
42
|
+
"claude-sonnet-4-5": {"input": 3.00, "output": 15.00, "cache_write": 3.75, "cache_read": 0.30},
|
|
43
|
+
"claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_write": 1.25, "cache_read": 0.10},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Rate lookup — unknown models fall back to the most expensive model in each
|
|
49
|
+
# family so we never under-count a call we forgot to register.
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def get_openai_rates(model: str, input_tokens: int = 0) -> dict[str, float]:
|
|
53
|
+
entry = OPENAI_PRICING.get(model)
|
|
54
|
+
if entry is None:
|
|
55
|
+
entry = OPENAI_PRICING["gpt-5.4"]
|
|
56
|
+
if entry.get("tiered"):
|
|
57
|
+
for tier in entry["tiers"]:
|
|
58
|
+
cap = tier.get("max_input_tokens")
|
|
59
|
+
if cap is None or input_tokens <= cap:
|
|
60
|
+
return tier
|
|
61
|
+
return entry["tiers"][-1]
|
|
62
|
+
return entry
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_anthropic_rates(model: str) -> dict[str, float]:
|
|
66
|
+
return ANTHROPIC_PRICING.get(model) or ANTHROPIC_PRICING["claude-opus-4-7"]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Cost calculators. `usage` is the SDK's raw usage object; we accept None
|
|
71
|
+
# and degrade to 0 so callers don't need to pre-check.
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def cost_of_openai_call(model: str, usage: Optional[Any]) -> float:
|
|
75
|
+
"""USD cost of one OpenAI chat.completions call from `response.usage`.
|
|
76
|
+
|
|
77
|
+
Understands the `prompt_tokens_details.cached_tokens` breakdown so cache
|
|
78
|
+
hits are billed at the discounted rate."""
|
|
79
|
+
if usage is None:
|
|
80
|
+
return 0.0
|
|
81
|
+
prompt_tokens = int(getattr(usage, "prompt_tokens", 0) or 0)
|
|
82
|
+
completion_tokens = int(getattr(usage, "completion_tokens", 0) or 0)
|
|
83
|
+
|
|
84
|
+
cached = 0
|
|
85
|
+
details = getattr(usage, "prompt_tokens_details", None)
|
|
86
|
+
if details is not None:
|
|
87
|
+
cached = int(getattr(details, "cached_tokens", 0) or 0)
|
|
88
|
+
|
|
89
|
+
rates = get_openai_rates(model, input_tokens=prompt_tokens)
|
|
90
|
+
uncached = max(prompt_tokens - cached, 0)
|
|
91
|
+
cached_rate = rates.get("cached_input", rates["input"])
|
|
92
|
+
total = (
|
|
93
|
+
uncached * rates["input"]
|
|
94
|
+
+ cached * cached_rate
|
|
95
|
+
+ completion_tokens * rates["output"]
|
|
96
|
+
)
|
|
97
|
+
return total / 1_000_000
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cost_of_anthropic_call(model: str, usage: Optional[Any]) -> float:
|
|
101
|
+
"""USD cost of one Anthropic messages.create call from `response.usage`."""
|
|
102
|
+
if usage is None:
|
|
103
|
+
return 0.0
|
|
104
|
+
input_tokens = int(getattr(usage, "input_tokens", 0) or 0)
|
|
105
|
+
output_tokens = int(getattr(usage, "output_tokens", 0) or 0)
|
|
106
|
+
cache_create = int(getattr(usage, "cache_creation_input_tokens", 0) or 0)
|
|
107
|
+
cache_read = int(getattr(usage, "cache_read_input_tokens", 0) or 0)
|
|
108
|
+
|
|
109
|
+
rates = get_anthropic_rates(model)
|
|
110
|
+
total = (
|
|
111
|
+
input_tokens * rates["input"]
|
|
112
|
+
+ cache_create * rates.get("cache_write", rates["input"])
|
|
113
|
+
+ cache_read * rates.get("cache_read", rates["input"])
|
|
114
|
+
+ output_tokens * rates["output"]
|
|
115
|
+
)
|
|
116
|
+
return total / 1_000_000
|
api/routers/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Accounting period-end actions (currently: foreign-currency revaluation)."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends
|
|
4
|
+
from lambda_erp.database import get_db
|
|
5
|
+
from lambda_erp.accounting.revaluation import run_period_revaluation
|
|
6
|
+
from lambda_erp.utils import nowdate
|
|
7
|
+
from api.auth import require_role
|
|
8
|
+
|
|
9
|
+
router = APIRouter(prefix="/accounting", tags=["accounting"])
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _resolve_company(db, company):
|
|
13
|
+
if company:
|
|
14
|
+
return company
|
|
15
|
+
companies = db.get_all("Company", fields=["name"], limit=1)
|
|
16
|
+
return companies[0]["name"] if companies else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@router.get("/currencies")
|
|
20
|
+
def list_currencies(company: str | None = None, _user: dict = Depends(require_role("viewer"))):
|
|
21
|
+
"""Currencies the books can transact in, for the document currency dropdown.
|
|
22
|
+
|
|
23
|
+
Returns the company base currency, every currency that has a Currency
|
|
24
|
+
Exchange rate on file, and any currency set as a default on a Company /
|
|
25
|
+
Customer / Supplier. Base currency first, then the rest alphabetically. We
|
|
26
|
+
only offer convertible currencies so a new document can't be denominated in
|
|
27
|
+
one the ledger has no rate path to base for.
|
|
28
|
+
"""
|
|
29
|
+
db = get_db()
|
|
30
|
+
company = _resolve_company(db, company)
|
|
31
|
+
base = (db.get_value("Company", company, "default_currency") if company else None) or "USD"
|
|
32
|
+
|
|
33
|
+
found = {base}
|
|
34
|
+
for row in db.sql(
|
|
35
|
+
'SELECT from_currency AS c FROM "Currency Exchange" '
|
|
36
|
+
'UNION SELECT to_currency AS c FROM "Currency Exchange"'
|
|
37
|
+
):
|
|
38
|
+
if row.get("c"):
|
|
39
|
+
found.add(row["c"])
|
|
40
|
+
for table in ("Company", "Customer", "Supplier"):
|
|
41
|
+
for row in db.sql(f'SELECT DISTINCT default_currency AS c FROM "{table}"'):
|
|
42
|
+
if row.get("c"):
|
|
43
|
+
found.add(row["c"])
|
|
44
|
+
|
|
45
|
+
others = sorted(found - {base})
|
|
46
|
+
return {"base_currency": base, "currencies": [base, *others]}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.post("/revaluation")
|
|
50
|
+
def period_revaluation(data: dict, _user: dict = Depends(require_role("manager"))):
|
|
51
|
+
"""Preview or post period-end FX revaluation of open foreign balances.
|
|
52
|
+
|
|
53
|
+
Body: {company?, date?, post?}. With post=false (default) it's a dry run —
|
|
54
|
+
returns the per-balance breakdown without touching the ledger. With
|
|
55
|
+
post=true it posts the revaluation + a next-day auto-reversal.
|
|
56
|
+
"""
|
|
57
|
+
db = get_db()
|
|
58
|
+
company = _resolve_company(db, data.get("company"))
|
|
59
|
+
if not company:
|
|
60
|
+
return {"detail": "No company found; create one first."}
|
|
61
|
+
return run_period_revaluation(
|
|
62
|
+
company, data.get("date") or nowdate(), post=bool(data.get("post", False))
|
|
63
|
+
)
|
api/routers/admin.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Admin-only observability endpoints.
|
|
2
|
+
|
|
3
|
+
Currently exposes the demo spend overview. Add future admin-only
|
|
4
|
+
reports here to keep `require_admin`-gated routes together.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Depends
|
|
12
|
+
|
|
13
|
+
from api.auth import require_admin
|
|
14
|
+
from api.demo_limits import init_schema, limiter
|
|
15
|
+
from lambda_erp.database import get_db
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Fixed windows the UI offers. Ordered for display.
|
|
22
|
+
_WINDOWS = [
|
|
23
|
+
("1h", 3600),
|
|
24
|
+
("2h", 2 * 3600),
|
|
25
|
+
("4h", 4 * 3600),
|
|
26
|
+
("12h", 12 * 3600),
|
|
27
|
+
("24h", 24 * 3600),
|
|
28
|
+
("7d", 7 * 24 * 3600),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _window_totals(now: float, seconds: int) -> dict:
|
|
33
|
+
db = get_db()
|
|
34
|
+
cutoff = now - seconds
|
|
35
|
+
|
|
36
|
+
totals = db.conn.execute(
|
|
37
|
+
'SELECT '
|
|
38
|
+
' COALESCE(SUM(cost_usd), 0) AS total_usd, '
|
|
39
|
+
' COALESCE(SUM(CASE WHEN role = "public_manager" THEN cost_usd ELSE 0 END), 0) AS demo_usd, '
|
|
40
|
+
' COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, '
|
|
41
|
+
' COALESCE(SUM(completion_tokens), 0) AS completion_tokens, '
|
|
42
|
+
' COUNT(*) AS call_count, '
|
|
43
|
+
' COUNT(DISTINCT ip) AS unique_ips '
|
|
44
|
+
'FROM "Demo Spend Log" WHERE ts > ?',
|
|
45
|
+
(cutoff,),
|
|
46
|
+
).fetchone()
|
|
47
|
+
|
|
48
|
+
by_provider_rows = db.conn.execute(
|
|
49
|
+
'SELECT provider, '
|
|
50
|
+
' COALESCE(SUM(cost_usd), 0) AS cost_usd, '
|
|
51
|
+
' COUNT(*) AS call_count, '
|
|
52
|
+
' COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, '
|
|
53
|
+
' COALESCE(SUM(completion_tokens), 0) AS completion_tokens '
|
|
54
|
+
'FROM "Demo Spend Log" WHERE ts > ? GROUP BY provider',
|
|
55
|
+
(cutoff,),
|
|
56
|
+
).fetchall()
|
|
57
|
+
|
|
58
|
+
by_provider = {
|
|
59
|
+
(row["provider"] or "unknown"): {
|
|
60
|
+
"cost_usd": round(row["cost_usd"], 6),
|
|
61
|
+
"call_count": int(row["call_count"]),
|
|
62
|
+
"prompt_tokens": int(row["prompt_tokens"]),
|
|
63
|
+
"completion_tokens": int(row["completion_tokens"]),
|
|
64
|
+
}
|
|
65
|
+
for row in by_provider_rows
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
"total_usd": round(totals["total_usd"], 6),
|
|
70
|
+
"demo_usd": round(totals["demo_usd"], 6),
|
|
71
|
+
"prompt_tokens": int(totals["prompt_tokens"]),
|
|
72
|
+
"completion_tokens": int(totals["completion_tokens"]),
|
|
73
|
+
"call_count": int(totals["call_count"]),
|
|
74
|
+
"unique_ips": int(totals["unique_ips"] or 0),
|
|
75
|
+
"by_provider": by_provider,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/demo-spend")
|
|
80
|
+
def demo_spend_overview(_user: dict = Depends(require_admin)):
|
|
81
|
+
"""Per-window spend breakdown, caps, and top-10 IPs over 24h.
|
|
82
|
+
|
|
83
|
+
Only `public_manager` spend counts against the demo cap — `demo_usd`
|
|
84
|
+
in each window is the number the global cap compares against. Non-
|
|
85
|
+
demo calls (your own admin/manager sessions) are included in
|
|
86
|
+
`total_usd` so you can see the full OpenAI + Anthropic bill from this
|
|
87
|
+
deployment.
|
|
88
|
+
"""
|
|
89
|
+
init_schema()
|
|
90
|
+
now = time.time()
|
|
91
|
+
|
|
92
|
+
windows = {name: _window_totals(now, seconds) for name, seconds in _WINDOWS}
|
|
93
|
+
|
|
94
|
+
db = get_db()
|
|
95
|
+
top_ip_rows = db.conn.execute(
|
|
96
|
+
'SELECT ip, role, '
|
|
97
|
+
' COALESCE(SUM(cost_usd), 0) AS cost_usd, '
|
|
98
|
+
' COUNT(*) AS call_count '
|
|
99
|
+
'FROM "Demo Spend Log" WHERE ts > ? '
|
|
100
|
+
'GROUP BY ip, role '
|
|
101
|
+
'ORDER BY cost_usd DESC '
|
|
102
|
+
'LIMIT 10',
|
|
103
|
+
(now - 24 * 3600,),
|
|
104
|
+
).fetchall()
|
|
105
|
+
top_ips_24h = [
|
|
106
|
+
{
|
|
107
|
+
"ip": row["ip"] or "unknown",
|
|
108
|
+
"role": row["role"],
|
|
109
|
+
"cost_usd": round(row["cost_usd"], 6),
|
|
110
|
+
"call_count": int(row["call_count"]),
|
|
111
|
+
}
|
|
112
|
+
for row in top_ip_rows
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"caps": {
|
|
117
|
+
"global_hourly_usd": limiter.global_hourly_usd,
|
|
118
|
+
"per_ip_hourly_usd": limiter.per_ip_hourly_usd,
|
|
119
|
+
},
|
|
120
|
+
"windows": windows,
|
|
121
|
+
"top_ips_24h": top_ips_24h,
|
|
122
|
+
}
|