llmage/llmage/accounting.py

408 lines
13 KiB
Python

import asyncio
import json
import time
from datetime import datetime, timedelta
from appPublic.log import exception, debug, info
from appPublic.uniqueID import getID
from appPublic.dictObject import DictObject
from sqlor.dbpools import get_sor_context
from ahserver.serverenv import ServerEnv
from accounting.consume import consume_accounting
from accounting.getaccount import getCustomerBalance
from .utils import *
async def llm_charging(ppid, llmusage):
env = ServerEnv()
usages = llmusage.usages
if isinstance(usages, str):
usages = json.loads(usages)
prices = await env.buffered_charging(ppid, usages)
if prices is None:
e = Exception(f'{ppid=}, {usages=}{llmusage.id=} env.buffered_charging() return None')
exception(f'{e}')
raise e
amount = 0
for p in prices:
amount += p.amount
discount = await env.get_customer_discount(llmusage.ownerid,
llmusage.userorgid)
# Get pricing program currency (provider's settlement currency)
cost_currency = 'CNY'
try:
pp = await env.get_ppid_pricing(ppid)
if pp and hasattr(pp, 'pp') and hasattr(pp.pp, 'currency'):
cost_currency = pp.pp.currency or 'CNY'
except Exception:
pass
# Get user's billing currency
user_currency = 'CNY'
try:
user_currency = await env.get_user_currency(llmusage.userorgid)
except Exception:
pass
# Convert cost to user currency at real-time rate
cost_in_user_currency = amount
if cost_currency != user_currency:
rate = await env.get_exchange_rate(cost_currency, user_currency, 'buy_rate')
cost_in_user_currency = round(amount * rate, 2)
# Convert both to base currency (CNY) for reporting
amount_base = await env.convert_to_base(cost_in_user_currency, user_currency, 'sell_rate')
return DictObject(**{
'original_amount': amount,
'amount': cost_in_user_currency * discount,
'cost_currency': cost_currency,
'amount_currency': user_currency,
'amount_base': amount_base * discount,
'cost_base': await env.convert_to_base(amount, cost_currency, 'buy_rate'),
})
async def checkCustomerBalance(llmid, userid, userorgid, catelogid=None):
if llmid is None:
debug(f'checkCustomerBalance(): llmid is None')
return False
env = ServerEnv()
llm = await get_llmage_llm(llmid)
if llm.ownerid == userorgid:
debug(f'self orgid user')
return True
balance = 0.00
tpac = await get_user_tpac(userid)
if tpac:
debug(f'{tpac=}, get tpac balance')
balance = await get_tpac_balance(tpac, userid)
else:
debug(f'{tpac=}, get local balance')
async with get_sor_context(env, 'accounting') as sor:
balance = await getCustomerBalance(sor, userorgid)
bal = 0 if balance is None else balance
if llm.min_balance is None:
llm.min_balance = 0.00
if not llm.ppid:
debug(f'{llm.ppid=} is empty, model unavailable')
return False
try:
await env.get_ppid_pricing(llm.ppid)
except Exception as e:
debug(f'{llm.ppid=} has no pricing data for today: {e}')
return False
ret = llm.min_balance < bal
debug(f'{llm.ppid=}, {llm.min_balance=}, {bal=}, pricing OK')
return ret
async def llm_accounting(llmusage):
env = ServerEnv()
llmid = llmusage.llmid
llm = await get_llmage_llm(llmid)
if llm is None:
async with get_sor_context(env, 'llmage') as sor:
ns = {
'id': llmusage.id,
'accounting_status': 'failed'
}
await sor.U('llmusage', ns)
e = Exception(f'llm not found({llmid})')
exception(f'{e}')
raise e
if llm.ppid is None:
async with get_sor_context(env, 'llmage') as sor:
ns = {
'id': llmusage.id,
'accounting_status': 'failed'
}
await sor.U('llmusage', ns)
e = Exception(f'llm ({llmid}) donot has a pricing_program')
exception(f'{e}')
raise e
customerid = llmusage.userorgid
userid = llmusage.userid
resellerid = llm.ownerid
providerid = llm.providerid
trans_amount = llmusage.amount
trans_cost = llmusage.cost
amount_currency = getattr(llmusage, 'amount_currency', 'CNY')
cost_currency = getattr(llmusage, 'cost_currency', 'CNY')
amount_base = getattr(llmusage, 'amount_base', trans_amount)
cost_base = getattr(llmusage, 'cost_base', trans_cost)
async with get_sor_context(env, 'llmage') as sor:
biz_date = await env.get_business_date(sor)
timestamp = env.timestampstr()
orderid = getID()
order = {
"id": orderid,
"customerid": customerid,
"resellerid": resellerid,
"order_date": biz_date,
"order_status": "1", # accounted
"business_op": "PAY",
"amount": trans_amount,
"userid": userid,
"productid": llmid
}
await sor.C('biz_order', order)
orderdetail = {
"id": getID(),
"orderid": orderid,
"productid": llmid,
"product_cnt": 1,
"trans_amount": trans_amount,
"currency": amount_currency,
}
await sor.C('biz_orderdetail', orderdetail)
ais = []
if customerid != resellerid:
ai0 = DictObject()
ai0.action = 'PAY'
ai0.customerid = customerid
ai0.resellerid = resellerid
ai0.providerid = providerid
ai0.biz_date = biz_date
ai0.timestamp = timestamp
ai0.productid = llmid
ai0.transamt = trans_amount
ai0.currency = amount_currency
ai0.base_amount = amount_base
ai0.variable = {
"交易金额": trans_amount,
"交易币种": amount_currency,
"折本位币": amount_base,
"交易手续费": 0
}
ais.append(ai0)
ai1 = DictObject()
ai1.action = 'PAY*'
ai1.customerid = customerid
ai1.resellerid = resellerid
ai1.providerid = providerid
ai1.biz_date = biz_date
ai1.timestamp = timestamp
ai1.providerid = providerid
ai1.productid = llmid
ai1.transamt = trans_cost
ai1.currency = cost_currency
ai1.base_amount = cost_base
ai1.variable = {
"采购成本": trans_cost
}
ais.append(ai1)
await consume_accounting(sor, orderid, ais)
llmusage.accounting_status = 'accounted'
ns = {
'id': llmusage.id,
'accounting_status': 'accounted'
}
await sor.U('llmusage', ns)
async def get_accounting_llmusages(luid=None):
env = ServerEnv()
lus = []
t = time.time()
dt = datetime.fromtimestamp(t)
tsstr = dt.strftime('%Y-%m-%d %H:%M:%S.') + f'{dt.microsecond // 1000:03d}'
async with get_sor_context(env, 'llmage') as sor:
sql = """select a.*, b.model, c.ppid
from llmusage a, llm b, llm_api_map c
where a.llmid = b.id
and a.llmid = c.llmid
and c.isdefaultcatelog = '1'
and a.status = 'SUCCEEDED'
and a.use_time < ${tsstr}$
and a.accounting_status='created'"""
ns = {'tsstr': tsstr}
if luid:
sql += " and a.id=${luid}$"
ns['luid'] = luid
recs = await sor.sqlExe(sql, ns)
for r in recs:
if r.usages is None:
try:
output = await get_lastoutput(r.ioinfo)
except Exception as e:
continue
r.usages = output.get('usage')
if r.usages is None:
debug(f'{r.usages=} is None, accounting failed')
await llm_accoung_failed(r.id, reason='usages is None')
continue
d = None
try:
debug(f'{r.ppid=}, {r.usages=} {r.id=}')
d = await llm_charging(r.ppid, r)
except Exception as e:
exception(f'{r.ppid=}, {r.usages=} llm_charging() failed,{e}')
await llm_accoung_failed(r.id, reason=f'llm_charging failed: {e}')
continue
r.amount = d.amount
ns = {
'id': r.id,
'amount': r.amount,
'amount_currency': getattr(d, 'amount_currency', 'CNY'),
'amount_base': getattr(d, 'amount_base', r.amount),
'cost_currency': getattr(d, 'cost_currency', 'CNY'),
'cost_base': getattr(d, 'cost_base', 0),
'usage': json.dumps(r.usage, ensure_ascii=False, indent=4)
}
await sor.U('llmusage', ns)
lus.append(r)
return lus
async def llm_accoung_failed(luid, reason=None):
env = ServerEnv()
async with get_sor_context(env, 'llmage') as sor:
await sor.U('llmusage', {
'id': luid,
'accounting_status': 'failed'
})
# Also record in the failed accounting table for tracking
recs = await sor.R('llmusage', {'id': luid})
if recs:
r = recs[0]
failed_id = getID()
failed_rec = {
'id': failed_id,
'llmusageid': luid,
'llmid': r.llmid,
'userid': r.userid,
'userorgid': r.userorgid,
'use_date': r.use_date,
'use_time': r.use_time,
'amount': r.amount,
'failed_reason': reason or 'accounting failed',
'failed_time': env.timestampstr(),
'retry_count': 0,
'handled': '0'
}
await sor.C('llmusage_accounting_failed', failed_rec)
async def backup_accounted_llmusage(cutoff_date):
"""Backup accounted records with use_date < cutoff_date to history table."""
env = ServerEnv()
ts = env.timestampstr()
async with get_sor_context(env, 'llmage') as sor:
# Step 0: Count records to backup
count_sql = """SELECT COUNT(*) as cnt FROM llmusage
WHERE accounting_status='accounted' AND use_date < ${cutoff_date}$"""
count_recs = await sor.sqlExe(count_sql, {'cutoff_date': cutoff_date})
total = count_recs[0].cnt if count_recs else 0
if total == 0:
debug(f'backup_accounted_llmusage: no records to backup for use_date < {cutoff_date}')
return 0
debug(f'backup_accounted_llmusage: {total} records to backup')
# Step 1: INSERT INTO history SELECT from main table
insert_sql = """INSERT INTO llmusage_history
(id, llmid, use_date, use_time, userid, usages, ioinfo, transno, responsed_seconds, finish_seconds, status, taskid, amount, userorgid, ownerid, accounting_status, tenantid, backup_time)
SELECT id, llmid, use_date, use_time, userid, usages, ioinfo, transno, responsed_seconds, finish_seconds, status, taskid, amount, userorgid, ownerid, accounting_status, tenantid, ${ts}$
FROM llmusage
WHERE accounting_status='accounted' AND use_date < ${cutoff_date}$"""
await sor.execute(insert_sql, {'cutoff_date': cutoff_date, 'ts': ts})
debug(f'backup_accounted_llmusage: inserted {total} records to history')
# Step 2: DELETE from main table
delete_sql = """DELETE FROM llmusage
WHERE accounting_status='accounted' AND use_date < ${cutoff_date}$"""
await sor.execute(delete_sql, {'cutoff_date': cutoff_date})
debug(f'backup_accounted_llmusage: deleted {total} records from main table')
return total
async def get_failed_accounting_records(filters=None, page=1, page_size=50):
"""Search failed accounting records with optional filters.
filters: dict with optional keys:
- userorgid: filter by user organization
- llmid: filter by model ID
- handled: '0' or '1'
- start_date: filter use_date >= start_date
- end_date: filter use_date <= end_date
"""
async with get_sor_context(ServerEnv(), 'llmage') as sor:
conditions = []
ns = {}
if filters:
if filters.get('userorgid'):
conditions.append("userorgid=${userorgid}$")
ns['userorgid'] = filters['userorgid']
if filters.get('llmid'):
conditions.append("llmid=${llmid}$")
ns['llmid'] = filters['llmid']
if filters.get('handled') is not None:
conditions.append("handled=${handled}$")
ns['handled'] = filters['handled']
if filters.get('start_date'):
conditions.append("use_date>=${start_date}$")
ns['start_date'] = filters['start_date']
if filters.get('end_date'):
conditions.append("use_date<=${end_date}$")
ns['end_date'] = filters['end_date']
where = ""
if conditions:
where = "where " + " and ".join(conditions)
# Count total
count_sql = f"select count(*) as cnt from llmusage_accounting_failed {where}"
count_recs = await sor.sqlExe(count_sql, ns)
total = count_recs[0].cnt if count_recs else 0
# Query with pagination
offset = (page - 1) * page_size
query_sql = f"""select * from llmusage_accounting_failed {where}
order by failed_time desc limit {page_size} offset {offset}"""
recs = await sor.sqlExe(query_sql, ns)
return {
'total': total,
'page': page,
'page_size': page_size,
'records': recs
}
async def backend_accounting():
env = ServerEnv()
info(f"backend accounting started ...")
last_backup_date = None
while True:
try:
lus = await get_accounting_llmusages()
info(f"accounting loop: got {len(lus)} records")
except Exception as e:
exception(f"get_accounting_llmusages failed: {e}")
lus = []
for lu in lus:
try:
tpac = await get_user_tpac(lu.userid)
if tpac:
debug(f'{lu.id=},{lu.userid=}, {tpac=}, go tpac')
await tpac_accounting(tpac, lu.userid, lu.llmid, lu.amount, lu.usages, lu.id, lu.model)
else:
debug(f'{lu.id=},{lu.userid=}, {tpac=}, go local')
await llm_accounting(lu)
# 记账成功,清理对应的失败记录
try:
async with get_sor_context(env, 'llmage') as sor:
await sor.execute(
"DELETE FROM llmusage_accounting_failed WHERE llmusageid=${luid}$",
{'luid': lu.id}
)
except Exception as e2:
debug(f'清理失败记录异常(不影响记账): {e2}')
except Exception as e:
exception(f'{e}, {lu.id=}')
await llm_accoung_failed(lu.id, reason=str(e))
# Check if date changed, trigger backup once per day
today = datetime.now().strftime('%Y-%m-%d')
if today != last_backup_date:
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
last_backup_date = today
try:
debug(f'date changed to {today}, triggering backup for use_date < {yesterday}')
await backup_accounted_llmusage(yesterday)
except Exception as e:
exception(f'backup_accounted_llmusage failed: {e}')
await asyncio.sleep(10)