From 5936a2f328ec309dd2b6537383c8530476b165cc Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 20 May 2026 18:22:23 +0800 Subject: [PATCH] feat: implement sync engine, API handlers, DAPI auth, HTTP client - Sync engine: BaseSync abstract class + 4 sync modules (users/pricing/uapi/llmage) - Checkpoint management via sync_state table - Batch processing with retry and exponential backoff - Incremental fetch from Sage DB via sqlor - UPSERT to local cache tables - API handlers: balance/accounting/users/pricing/health - Balance: cache lookup + Sage fallback - Accounting: create with idempotency, query with filters/pagination - Users: keyword search, org filter - Pricing: filter by ppid/llmid/type/status - Health: basic + readiness checks (DB connectivity) - DAPI auth: middleware + authenticate_request function - HMAC-SHA256 signature verification - Timestamp window validation - Sage downapikey table lookup - HTTP client: SageHttpClient with aiohttp - Auto DAPI signature injection - Connection pooling, retry, timeout - Router: 12 routes registered - Module init: load_sageapi() wires everything to ServerEnv --- .gitignore | 2 + sageapi/__init__.py | 26 + .../api/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 116 bytes .../__pycache__/accounting.cpython-310.pyc | Bin 0 -> 5041 bytes .../api/__pycache__/balance.cpython-310.pyc | Bin 0 -> 3858 bytes .../api/__pycache__/health.cpython-310.pyc | Bin 0 -> 2909 bytes .../api/__pycache__/pricing.cpython-310.pyc | Bin 0 -> 3368 bytes sageapi/api/__pycache__/users.cpython-310.pyc | Bin 0 -> 2956 bytes sageapi/api/accounting.py | 184 ++++--- sageapi/api/balance.py | 112 +++- sageapi/api/health.py | 115 ++-- sageapi/api/pricing.py | 129 +++-- sageapi/api/users.py | 111 ++-- .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 118 bytes .../__pycache__/cache_manager.cpython-310.pyc | Bin 0 -> 5306 bytes sageapi/init.py | 110 ++-- sageapi/middleware/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 123 bytes .../__pycache__/dapi_auth.cpython-310.pyc | Bin 0 -> 6896 bytes sageapi/middleware/dapi_auth.py | 254 +++++++++ sageapi/router.py | 115 ++-- sageapi/sync/__init__.py | 16 + .../sync/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 457 bytes .../__pycache__/base_sync.cpython-310.pyc | Bin 0 -> 7494 bytes .../__pycache__/llmage_sync.cpython-310.pyc | Bin 0 -> 6337 bytes .../__pycache__/pricing_sync.cpython-310.pyc | Bin 0 -> 5282 bytes .../__pycache__/uapi_sync.cpython-310.pyc | Bin 0 -> 5266 bytes .../__pycache__/user_sync.cpython-310.pyc | Bin 0 -> 5762 bytes sageapi/sync/base_sync.py | 319 +++++++---- sageapi/sync/llmage_sync.py | 179 ++++-- sageapi/sync/pricing_sync.py | 160 ++++-- sageapi/sync/uapi_sync.py | 166 ++++-- sageapi/sync/user_sync.py | 191 ++++--- .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 118 bytes .../utils/__pycache__/crypto.cpython-310.pyc | Bin 0 -> 2908 bytes .../__pycache__/http_client.cpython-310.pyc | Bin 0 -> 12210 bytes sageapi/utils/http_client.py | 511 +++++++++++++++--- sync/cache_tables.sql | 122 +++++ tests/__init__.py | 1 + tests/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 133 bytes ...est_dapi_auth.cpython-310-pytest-9.0.3.pyc | Bin 0 -> 26609 bytes ...t_http_client.cpython-310-pytest-9.0.3.pyc | Bin 0 -> 20311 bytes tests/test_dapi_auth.py | 458 ++++++++++++++++ tests/test_http_client.py | 288 ++++++++++ 44 files changed, 2854 insertions(+), 716 deletions(-) create mode 100644 .gitignore create mode 100644 sageapi/api/__pycache__/__init__.cpython-310.pyc create mode 100644 sageapi/api/__pycache__/accounting.cpython-310.pyc create mode 100644 sageapi/api/__pycache__/balance.cpython-310.pyc create mode 100644 sageapi/api/__pycache__/health.cpython-310.pyc create mode 100644 sageapi/api/__pycache__/pricing.cpython-310.pyc create mode 100644 sageapi/api/__pycache__/users.cpython-310.pyc create mode 100644 sageapi/cache/__pycache__/__init__.cpython-310.pyc create mode 100644 sageapi/cache/__pycache__/cache_manager.cpython-310.pyc create mode 100644 sageapi/middleware/__init__.py create mode 100644 sageapi/middleware/__pycache__/__init__.cpython-310.pyc create mode 100644 sageapi/middleware/__pycache__/dapi_auth.cpython-310.pyc create mode 100644 sageapi/middleware/dapi_auth.py create mode 100644 sageapi/sync/__pycache__/__init__.cpython-310.pyc create mode 100644 sageapi/sync/__pycache__/base_sync.cpython-310.pyc create mode 100644 sageapi/sync/__pycache__/llmage_sync.cpython-310.pyc create mode 100644 sageapi/sync/__pycache__/pricing_sync.cpython-310.pyc create mode 100644 sageapi/sync/__pycache__/uapi_sync.cpython-310.pyc create mode 100644 sageapi/sync/__pycache__/user_sync.cpython-310.pyc create mode 100644 sageapi/utils/__pycache__/__init__.cpython-310.pyc create mode 100644 sageapi/utils/__pycache__/crypto.cpython-310.pyc create mode 100644 sageapi/utils/__pycache__/http_client.cpython-310.pyc create mode 100644 sync/cache_tables.sql create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-310.pyc create mode 100644 tests/__pycache__/test_dapi_auth.cpython-310-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_http_client.cpython-310-pytest-9.0.3.pyc create mode 100644 tests/test_dapi_auth.py create mode 100644 tests/test_http_client.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a60b85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc diff --git a/sageapi/__init__.py b/sageapi/__init__.py index e69de29..57872ca 100644 --- a/sageapi/__init__.py +++ b/sageapi/__init__.py @@ -0,0 +1,26 @@ +"""SageAPI - Sage data caching and proxy API server. + +Provides cached access to Sage data (users, pricing, uapi, llmage) +with DAPI authentication and independent multi-instance deployment. +""" + +__version__ = '0.1.0' + +# Public API exports +from sageapi.sync import run_all_syncs, UserSync, PricingSync, UapiSync, LlmageSync +from sageapi.router import Router, setup_routes +from sageapi.cache.cache_manager import CacheManager +from sageapi.middleware.dapi_auth import authenticate_request, DapiAuthMiddleware + +__all__ = [ + 'run_all_syncs', + 'UserSync', + 'PricingSync', + 'UapiSync', + 'LlmageSync', + 'Router', + 'setup_routes', + 'CacheManager', + 'authenticate_request', + 'DapiAuthMiddleware', +] diff --git a/sageapi/api/__pycache__/__init__.cpython-310.pyc b/sageapi/api/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3ed3f28b9bccc7d391dca472e43df92c76ac13f GIT binary patch literal 116 zcmd1j<>g`k0+lA-ED-$|L?8o3AjbiSi&=m~3PUi1CZpdWHa^HWN5Qtdz*ikW}}3j+Y7I~1b; literal 0 HcmV?d00001 diff --git a/sageapi/api/__pycache__/accounting.cpython-310.pyc b/sageapi/api/__pycache__/accounting.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..151b90ebb4afe859123fe87f771e496f46dfbd81 GIT binary patch literal 5041 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 literal 0 HcmV?d00001 diff --git a/sageapi/api/__pycache__/balance.cpython-310.pyc b/sageapi/api/__pycache__/balance.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efffddb14daaefb7dda42cc24e610ee691b6b25c GIT binary patch literal 3858 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 YT+Ts5QPp;di>tUns?fT`v+A{<0iaXkwEzGB literal 0 HcmV?d00001 diff --git a/sageapi/api/__pycache__/pricing.cpython-310.pyc b/sageapi/api/__pycache__/pricing.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30e9a9cd962a958f3e909bea0bdefdcfb821d40a GIT binary patch literal 3368 zcmd5<&2QYs6`vV?b3e3N$+GNxh;5zN>k10m#Qg}=qK+-qDiT?--3Z=835vs&C~(R3 za8|N5WS~Mh6$sFC8w1iY{V$5%`X>y~UV8Gmm(+)#Z-%SQD)y8~<#_p^Nkiou`*RH@Xdal`hcBpX=Qw)gGeG zYop(6u{ey=UGMhRhPM}_G>O=X>u#~^Af{37MJXL*amu}3#=IxPh#g7eFbMY|>J7$Y zX~2UO_qp6z2a8&erWp@-oTd3D^tIdRQHKbFMmxh@VML5&48+#m54N%_$$@nqM(iMB zYw5vr3_hO!7>LjZgP6W>bI!cN+sx8P7)jEPsi=>q_4&~t5@rrl4RbILb|ay|QiUT~ zKaWSCWf32;RA}HPH01)v&m6S!+x$&N|0+S~_n6{cLT31p6(gsL1wxy&^CDlMWvx-tf$y!S0u;0;H+CDW4=L}y)DW-E}jm{Uf zKci=)tQR`Hw1^609+eHcAf@zjVE|n$8bGf+v&ts7PS7dr=?uSzHW0mf5B+JooGE5b zouXd2XQYyqE$$XAUOOS|S<#X&C0EdCoxVoN8G#7A4l8RYn+DkYrD$E$DSyo=f3sja z%cbpKi&?OJV;3nuZ-O63>Ev90FJ{3v$mkNZ-YVLL73xSwCt9^GjMCbV#_P`ic?gk~ zzsK-yqtw$8Z~hS<=@^x5j7D>OhJLST%h;TNCD)7g2?oFL9k*c&y;`pFph;qqh2sf z_^1(6?-$;+Pi3l<*O)xeqgj^Rr;zt0;`6!nyb=D=_?jAP33o@ zxgLZ(K8UW5%j^Zv`Ec#SwL6czJNJLP`RK;Yju+%!2;i~);QmMF z(eo8}c+8egzj3{(XFNKl%br^LvW!BM_-W9OmM7z7+Am)iH+B)HgCY0jHo-a@@~Pf1 zjn6f&8W|25i_-9D*{e3W>_xp^q;~1ck3HSz*()cnAkh!_1^+K^{DOM)?uG9C2Y1&V zcpp5T+V%zghZ`SlJn}}icYl5T;TmwxM7=3A2sB%OpbDL3hdH|h>?1ZWqkKsLw~CBJ zZ-v0`dz}`ur1|V_#Qo~H`E*CdTIlxFr0Q&(a5 z1slL?V}jS@tKmcniWhNic~q|3~=rKfz4}*Duuvh2Afr`-PoPkZHPzOe{954E`2z*c%YP(d!ae z&Ijxq`PIZ@^icr77QwF5R5--m1YRKv`+?-HN%EE?Z%cARlIxOmB$0JdAro7YoCl<1 zSIf951wWKz83KwT;YsD$(lK`Xek14J#yAp&u zdNo?|>|5!Kuza6pq3;XV_j^OQ@J7DR;7*HFaD)jJ6L6ntAQ24!@~z=c5{D~Ew#(!{ z5EV?(P;kCNcLwrSUO{|2*vr+`e5GmthO2KS@rcR2PK8nhRmIa@t;)$SC1U0ByXvTJ U*Z_YmcmV`;fWh&v{-U|@U*G0VF#rGn literal 0 HcmV?d00001 diff --git a/sageapi/api/__pycache__/users.cpython-310.pyc b/sageapi/api/__pycache__/users.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..331b3a828109fcc4d504de576f2b277011beecc3 GIT binary patch literal 2956 zcmd6p&2JmW6~Jd^zsTjMKFmmN5)WFX6=Oj`g0xKoC8%RlwNQt0EC)h}0*e)AMXgKj z(z8p;Vs`@9MH+=~BSg{tHF=N6fXC8a?(@p(y&^EUids{(vm8Z{EClGduJ4 zy&pHO)m#GC_21VX%+(0_J9aK!CUkyI2u=1u5u`^16>LaHtj8$o#?a^)(3(SYWc4g< zvxfG_={ciPucZ6-usm{muGXDlWmN4|L4QRoiFvW`)aup5vbZ8zPt9Ik7(1kWb=vtf z31q@=+}+{_ek6v0TzB2OGJX__AmKqI#&H;>d_R`_;UthpVKm@%%RwlVIrayEGGJ4xjH;J}(?HoWNGCE<22AK< zBwt>0pkUnpijn;XJmR-h&;b!l7=JQybNW?Ej!AB1)(J%Rl;(D3C)b3PnZn*6DSKdm zMpIKbUl_Y&hYU#Wgd{IzM817i6Dp(!4 zDQcpAVrR}NeT76+H1e`&X5}x*GnTs<6LU)>GZsl+5%Xvj3z-3OF{^;Q^32JrsdG$D zN|}91-z8f_v~H6>?&h_uc2dsVtaQo>TV7Ayte#emnfzT=$6K&va#Dp%V5baDw6sB@ zn)a!I&mXe-1$@NJ~%`#fua@A81?b*{oXWj%(^`b*Y;wZSI72komHWEXa5 z5|bPo#l9$sDsRvBo&4<(VuQcW;NB(CHIt(H_#nZ@eqW?boN*Kf#REd7AKj8IOw0<%eNDM-tlT7;kAW4*osjaLO@_7hWNHu>PPTSpB=lq_Z1iT+d`{7_Bp%}10W1O%(Y?hLnXc}1?sRV6=Qp=M?cQH~v(5d4_n~Sx?`?lv9N*J5 z%Vi1Y|JmgiuG!pBtZ7%=(i+##(@oJ2Mt(S4}ZI7)-Zsj{Khc^oJ=Ou`7l6!l@5VVI=Kfb}8G z!aks^^SB?3k()Yg+H*Ps^y`-JXF+5`KT8G)0e=#jE#TXG=_MW~Uh?6EKXQKGEF zgFptV+>a+w>cIw-9q;ca@VF9YwYC^ZELFAeDAv_+Q8ai?4y|6#Uy#_ZBrt2|HZ zv<2f$IuE|{+S6sL|1zJ{8+E!wOHbH;o2L9Jp54I>c%kTHoxZ#m6X}wXkZOv8J-1UUvr`+$_Or~!TR6)=b5R1q zm7xXFM=kB+fX{YjpOgd;A2m@~CjajZJY?rQgeRQy5cI_R79N(5o&paY%|lmIGv}O# zmCOMiQc;`nkmb(j4)9R7nupZ~H5jL9Mby8*|N6#s=@&uDeGX|j7zVnG_}&rUy0yO9 zZ8KdCIs@bti0bsK^ZLOG|IR|9Kw&5}C1)v;ubl5)z6WQ~XunYO@>M)w3#(ht*gJx} zdRhFs{$vY0+I5Wq`5G>Pg)P62;s+>JP`rWSM<{-XVig5KUxPuuj@mU4?NYIeHX7bU zaUBGFsyrM@{M;#m{w``*c7^x}y3{Xk@iy3!H4yBH`?A_t%V|LsRiSnkp^jX-`fsS7 z@`dVoA11WTZdbEkma*p+bg6(|AVzDNGj+5fY^2t(!1Zz3W^o8)p8VR{P>$z`xbJz& z^}PKFe1rv_CmDKS0F<2`jUnHpft?z#^6q4B81~nP@j&8tnhuz%X=1L6y)k|k78ITL y51``V+i<;TfO*CHKpaRs)rD~B!0DKkHVR^Yh@41}@g}KSC7@3eZulAX_x=YA#^8$p literal 0 HcmV?d00001 diff --git a/sageapi/api/accounting.py b/sageapi/api/accounting.py index 537149a..d61279d 100644 --- a/sageapi/api/accounting.py +++ b/sageapi/api/accounting.py @@ -2,7 +2,7 @@ Provides endpoints for creating and querying accounting records. Writing goes directly to the accounting_records table; reads -are served from the same table with optional date range filtering. +are served from the same table with optional filtering. """ from __future__ import annotations @@ -14,64 +14,86 @@ from typing import Any from appPublic.log import debug, error from sqlor.dbpools import DBPools +from ahserver.serverenv import ServerEnv async def create_accounting_record( customer_id: str, amount: float, - record_type: str = 'charge', - description: str = '', - **extra: Any, + llmid: str = '', + model_name: str = '', + pricing_id: str = '', + input_tokens: int | None = None, + output_tokens: int | None = None, + total_tokens: int | None = None, + quantity: float | None = None, + currency: str = 'CNY', + request_id: str = '', + transno: str = '', ) -> str: - """Create a new accounting record. - - Args: - customer_id: The customer identifier. - amount: The accounting amount (positive for charges, negative for credits). - record_type: Type of record (charge, credit, adjustment, etc.). - description: Optional description of the transaction. - - Returns: - JSON string with success flag and the created record ID. - """ + """Create a new accounting record with idempotency via request_id.""" result: dict[str, Any] = {'success': False, 'record_id': None} try: - from ahserver.serverenv import ServerEnv env = ServerEnv() dbname = env.get_module_dbname('sageapi') - if not dbname: result['error'] = 'No database configured for sageapi module' return json.dumps(result, ensure_ascii=False, default=str) - record_id = str(uuid.uuid4()) + record_id = request_id or str(uuid.uuid4()) now = time.strftime('%Y-%m-%d %H:%M:%S') + # Check idempotency + if request_id: + async with DBPools().sqlorContext(dbname) as sor: + existing = await sor.sqlExe( + "SELECT id FROM accounting_records WHERE request_id = ${request_id}$", + {'request_id': request_id}, + ) + if isinstance(existing, list) and existing: + result['success'] = True + result['record_id'] = existing[0].get('id', existing[0].get('id') if isinstance(existing[0], dict) else existing[0]) + result['duplicate'] = True + return json.dumps(result, ensure_ascii=False, default=str) + sql = """ INSERT INTO accounting_records - (id, customer_id, amount, record_type, description, created_at, extra) + (id, customer_id, llmid, model_name, pricing_id, + input_tokens, output_tokens, total_tokens, quantity, + amount, currency, request_id, transno, status, + created_at, updated_at) VALUES - (${id}$, ${customer_id}$, ${amount}$, ${record_type}$, ${description}$, ${created_at}$, ${extra}$) + (${id}$, ${customer_id}$, ${llmid}$, ${model_name}$, ${pricing_id}$, + ${input_tokens}$, ${output_tokens}$, ${total_tokens}$, ${quantity}$, + ${amount}$, ${currency}$, ${request_id}$, ${transno}$, 'accounted', + ${created_at}$, ${updated_at}$) """ + params = { + 'id': record_id, + 'customer_id': customer_id, + 'llmid': llmid, + 'model_name': model_name, + 'pricing_id': pricing_id, + 'input_tokens': input_tokens, + 'output_tokens': output_tokens, + 'total_tokens': total_tokens, + 'quantity': quantity, + 'amount': amount, + 'currency': currency, + 'request_id': request_id, + 'transno': transno, + 'created_at': now, + 'updated_at': now, + } async with DBPools().sqlorContext(dbname) as sor: - await sor.sqlExe(sql, { - 'id': record_id, - 'customer_id': customer_id, - 'amount': amount, - 'record_type': record_type, - 'description': description, - 'created_at': now, - 'extra': json.dumps(extra, ensure_ascii=False) if extra else None, - }) - - result['success'] = True - result['record_id'] = record_id - debug(f'Accounting record created: id={record_id}, customer={customer_id}, amount={amount}') + await sor.sqlExe(sql, params) + result['success'] = True + result['record_id'] = record_id except Exception as e: - error(f'Accounting record creation failed: {e}') + error(f'create_accounting_record error: {e}') result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) @@ -79,30 +101,19 @@ async def create_accounting_record( async def query_accounting_records( customer_id: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - limit: int = 100, - offset: int = 0, + date_from: str | None = None, + date_to: str | None = None, + llmid: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 50, ) -> str: - """Query accounting records with optional filters. - - Args: - customer_id: Filter by customer ID. - start_date: Filter records from this date (inclusive, YYYY-MM-DD). - end_date: Filter records up to this date (inclusive, YYYY-MM-DD). - limit: Maximum number of records to return. - offset: Number of records to skip. - - Returns: - JSON string with success flag and record data. - """ + """Query accounting records with filters and pagination.""" result: dict[str, Any] = {'success': False, 'data': [], 'total': 0} try: - from ahserver.serverenv import ServerEnv env = ServerEnv() dbname = env.get_module_dbname('sageapi') - if not dbname: result['error'] = 'No database configured for sageapi module' return json.dumps(result, ensure_ascii=False, default=str) @@ -113,41 +124,56 @@ async def query_accounting_records( if customer_id: conditions.append('customer_id = ${customer_id}$') params['customer_id'] = customer_id - if start_date: - conditions.append('created_at >= ${start_date}$') - params['start_date'] = start_date - if end_date: - conditions.append('created_at <= ${end_date}$') - params['end_date'] = end_date + if date_from: + conditions.append('created_at >= ${date_from}$') + params['date_from'] = date_from + if date_to: + conditions.append('created_at <= ${date_to}$') + params['date_to'] = date_to + if llmid: + conditions.append('llmid = ${llmid}$') + params['llmid'] = llmid + if status: + conditions.append('status = ${status}$') + params['status'] = status - where_clause = 'WHERE ' + ' AND '.join(conditions) if conditions else '' + where = 'WHERE ' + ' AND '.join(conditions) if conditions else '' # Count query - count_sql = f""" - SELECT COUNT(*) as cnt FROM accounting_records {where_clause} - """ - async with DBPools().sqlorContext(dbname) as sor: - count_rows = await sor.sqlExe(count_sql, params) - total = count_rows[0]['cnt'] if count_rows else 0 - result['total'] = total + count_sql = f"SELECT COUNT(*) as cnt FROM accounting_records {where}" + offset = (page - 1) * page_size - if total > 0: - data_sql = f""" - SELECT id, customer_id, amount, record_type, description, created_at, extra - FROM accounting_records - {where_clause} - ORDER BY created_at DESC - LIMIT ${limit}$ OFFSET ${offset}$ - """ - params['limit'] = limit - params['offset'] = offset - rows = await sor.sqlExe(data_sql, params) - result['data'] = [dict(r) for r in (rows or [])] + # Data query + data_sql = f""" + SELECT id, customer_id, llmid, model_name, pricing_id, + input_tokens, output_tokens, total_tokens, quantity, + amount, currency, request_id, transno, status, + created_at, updated_at + FROM accounting_records + {where} + ORDER BY created_at DESC + LIMIT {page_size} OFFSET {offset} + """ + + async with DBPools().sqlorContext(dbname) as sor: + count_result = await sor.sqlExe(count_sql, params) + if isinstance(count_result, list) and count_result: + result['total'] = count_result[0].get('cnt', 0) + elif isinstance(count_result, dict): + result['total'] = count_result.get('cnt', 0) + + data = await sor.sqlExe(data_sql, params) + if isinstance(data, dict): + result['data'] = data.get('rows', []) + elif isinstance(data, list): + result['data'] = data result['success'] = True + result['page'] = page + result['page_size'] = page_size except Exception as e: - error(f'Accounting query failed: {e}') + error(f'query_accounting_records error: {e}') result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) diff --git a/sageapi/api/balance.py b/sageapi/api/balance.py index d541544..ee0c547 100644 --- a/sageapi/api/balance.py +++ b/sageapi/api/balance.py @@ -1,7 +1,8 @@ """Customer balance query API handler. Provides the RESTful endpoint for querying customer account balances. -Reads from the local customer_balance cache table. +Reads from the local customer_balance cache table, with fallback to +real-time query from Sage acc_balance table. """ from __future__ import annotations @@ -10,15 +11,18 @@ import json from typing import Any from appPublic.log import debug, error -from sqlor.dbpools import DBPools +from sqlor.dbpools import DBPools, get_sor_context +from ahserver.serverenv import ServerEnv async def get_customer_balance(customer_id: str | None = None) -> str: """Query customer balance. + First checks the local customer_balance cache. If not found, + falls back to real-time query from Sage acc_balance table. + Args: - customer_id: Optional customer ID filter. If not provided, - returns all customer balances. + customer_id: Optional customer ID filter. Returns: JSON string with success flag and balance data. @@ -26,42 +30,92 @@ async def get_customer_balance(customer_id: str | None = None) -> str: result: dict[str, Any] = {'success': False, 'data': [], 'total': 0} try: - from ahserver.serverenv import ServerEnv env = ServerEnv() - dbname = env.get_module_dbname('sageapi') - - if not dbname: + cache_dbname = env.get_module_dbname('sageapi') + if not cache_dbname: result['error'] = 'No database configured for sageapi module' return json.dumps(result, ensure_ascii=False, default=str) - params: dict[str, Any] = {} - where_clause = '' - if customer_id: - where_clause = 'WHERE customer_id = ${customer_id}$' - params['customer_id'] = customer_id + async with DBPools().sqlorContext(cache_dbname) as sor: + params: dict[str, Any] = {} + where = '' + if customer_id: + where = 'WHERE id = ${customer_id}$' + params['customer_id'] = customer_id - sql = f""" - SELECT customer_id, balance, currency, updated_at - FROM customer_balance - {where_clause} - ORDER BY customer_id - """ - - async with DBPools().sqlorContext(dbname) as sor: + sql = f""" + SELECT id, balance, currency, credit_limit, + last_recharge, last_consumption, + status, cached_at + FROM customer_balance + {where} + ORDER BY id + """ data = await sor.sqlExe(sql, params) if isinstance(data, dict): - result['total'] = data.get('total', 0) - result['data'] = [dict(r) for r in data.get('rows', [])] - else: - rows = [dict(r) for r in (data or [])] - result['data'] = rows - result['total'] = len(rows) + result['total'] = data.get('total', len(data.get('rows', []))) + result['data'] = data.get('rows', []) + elif isinstance(data, list): + result['total'] = len(data) + result['data'] = data + + # If cache miss for specific customer, try real-time from Sage + if customer_id and not result['data']: + result['data'] = await _query_sage_balance(env, customer_id) + result['total'] = len(result['data']) result['success'] = True - debug(f'Balance query: returned {result["total"]} records') except Exception as e: - error(f'Balance query failed: {e}') + error(f'get_customer_balance error: {e}') + result['error'] = str(e) + + return json.dumps(result, ensure_ascii=False, default=str) + + +async def _query_sage_balance(env: ServerEnv, customer_id: str) -> list[dict]: + """Fallback: query Sage acc_balance table directly.""" + try: + async with get_sor_context(env, 'sage') as sor: + sql = """ + SELECT customer_id, balance, currency, status, updated_at + FROM acc_balance + WHERE customer_id = ${customer_id}$ + """ + rows = await sor.sqlExe(sql, {'customer_id': customer_id}) + if isinstance(rows, list): + return rows + elif isinstance(rows, dict): + return rows.get('rows', []) + except Exception as e: + error(f'_query_sage_balance error: {e}') + return [] + + +async def update_customer_balance(customer_id: str, balance: float) -> str: + """Update customer balance in cache (called by sync or accounting).""" + result: dict[str, Any] = {'success': False} + + try: + env = ServerEnv() + cache_dbname = env.get_module_dbname('sageapi') + + sql = """ + INSERT INTO customer_balance (id, balance, cached_at) + VALUES (${customer_id}$, ${balance}$, NOW()) + ON DUPLICATE KEY UPDATE + balance = ${balance}$, + cached_at = NOW() + """ + async with DBPools().sqlorContext(cache_dbname) as sor: + await sor.sqlExe(sql, { + 'customer_id': customer_id, + 'balance': balance, + }) + result['success'] = True + + except Exception as e: + error(f'update_customer_balance error: {e}') result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) diff --git a/sageapi/api/health.py b/sageapi/api/health.py index 1fed77b..dd11fdd 100644 --- a/sageapi/api/health.py +++ b/sageapi/api/health.py @@ -1,7 +1,6 @@ """Health check API handler. -Provides a simple endpoint for load balancer health checks and -system status monitoring. No authentication required. +Provides endpoints for service health and readiness checks. """ from __future__ import annotations @@ -10,51 +9,101 @@ import json import time from typing import Any -from appPublic.log import debug +from appPublic.log import debug, error from sqlor.dbpools import DBPools +from ahserver.serverenv import ServerEnv + +_START_TIME = time.time() async def health_check() -> str: - """Health check endpoint. + """Basic health check - returns service status.""" + uptime = time.time() - _START_TIME - Returns system status including database connectivity, - cache stats, and uptime information. - - Returns: - JSON string with health status. - """ - result: dict[str, Any] = { + result = { 'status': 'ok', - 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'), - 'database': 'unknown', - 'cache': {}, + 'service': 'sageapi', + 'uptime_seconds': round(uptime, 1), + 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%S%z'), + } + return json.dumps(result, ensure_ascii=False, default=str) + + +async def readiness_check() -> str: + """Readiness check - verifies database connectivity.""" + result: dict[str, Any] = { + 'status': 'unknown', + 'checks': {}, } - # Check database connectivity + # Check cache database connection try: - from ahserver.serverenv import ServerEnv env = ServerEnv() dbname = env.get_module_dbname('sageapi') - - if dbname: - async with DBPools().sqlorContext(dbname) as sor: - await sor.sqlExe('SELECT 1') - result['database'] = 'connected' + if not dbname: + result['checks']['cache_db'] = { + 'status': 'fail', + 'error': 'No database configured for sageapi module', + } else: - result['database'] = 'not_configured' - result['status'] = 'degraded' - + async with DBPools().sqlorContext(dbname) as sor: + rows = await sor.sqlExe('SELECT 1 as ping') + result['checks']['cache_db'] = { + 'status': 'ok', + 'dbname': dbname, + } except Exception as e: - result['database'] = f'error: {str(e)}' - result['status'] = 'unhealthy' + error(f'readiness_check cache_db error: {e}') + result['checks']['cache_db'] = { + 'status': 'fail', + 'error': str(e), + } - # Cache stats + # Check Sage database connection try: - from ..cache.cache_manager import _get_cache_manager - cm = _get_cache_manager() - result['cache'] = cm.stats() - except Exception: - result['cache'] = {'error': 'cache not initialized'} + from sqlor.dbpools import get_sor_context + async with get_sor_context(env, 'sage') as sor: + rows = await sor.sqlExe('SELECT 1 as ping') + result['checks']['sage_db'] = {'status': 'ok'} + except Exception as e: + error(f'readiness_check sage_db error: {e}') + result['checks']['sage_db'] = { + 'status': 'fail', + 'error': str(e), + } + + # Check sync state + try: + async with DBPools().sqlorContext(dbname) as sor: + sql = """ + SELECT entity_type, sync_status, last_sync_time + FROM sync_state + ORDER BY last_sync_time DESC + """ + rows = await sor.sqlExe(sql) + if isinstance(rows, list): + result['checks']['sync_status'] = { + 'status': 'ok', + 'entities': [ + { + 'entity_type': r.get('entity_type', ''), + 'sync_status': r.get('sync_status', ''), + 'last_sync_time': str(r.get('last_sync_time', '')), + } + for r in rows + ], + } + except Exception as e: + result['checks']['sync_status'] = { + 'status': 'fail', + 'error': str(e), + } + + # Overall status + all_ok = all( + check.get('status') == 'ok' + for check in result['checks'].values() + ) + result['status'] = 'ready' if all_ok else 'degraded' - debug(f'Health check: status={result["status"]}') return json.dumps(result, ensure_ascii=False, default=str) diff --git a/sageapi/api/pricing.py b/sageapi/api/pricing.py index 7af1a9d..f4ff158 100644 --- a/sageapi/api/pricing.py +++ b/sageapi/api/pricing.py @@ -1,7 +1,6 @@ -"""Pricing query API handler. +"""Pricing API handler. -Provides the RESTful endpoint for querying pricing information. -Reads from the local pricing_cache table synced from Sage. +Provides endpoint for querying cached pricing data. """ from __future__ import annotations @@ -11,72 +10,110 @@ from typing import Any from appPublic.log import debug, error from sqlor.dbpools import DBPools +from ahserver.serverenv import ServerEnv async def query_pricing( - program_id: str | None = None, - model: str | None = None, - limit: int = 200, - offset: int = 0, + ppid: str | None = None, + llmid: str | None = None, + pricing_type: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 50, ) -> str: - """Query pricing information from the local cache. - - Args: - program_id: Filter by pricing program ID. - model: Filter by model name (partial match). - limit: Maximum number of records to return. - offset: Number of records to skip. - - Returns: - JSON string with success flag and pricing data. - """ + """Query pricing from cache with filters.""" result: dict[str, Any] = {'success': False, 'data': [], 'total': 0} try: - from ahserver.serverenv import ServerEnv env = ServerEnv() dbname = env.get_module_dbname('sageapi') - if not dbname: result['error'] = 'No database configured for sageapi module' return json.dumps(result, ensure_ascii=False, default=str) conditions = [] - params: dict[str, Any] = {'limit': limit, 'offset': offset} + params: dict[str, Any] = {} - if program_id: - conditions.append('program_id = ${program_id}$') - params['program_id'] = program_id - if model: - conditions.append('model LIKE ${model}$') - params['model'] = f'%{model}%' + if ppid: + conditions.append('id = ${ppid}$') + params['ppid'] = ppid + if llmid: + conditions.append('llmid = ${llmid}$') + params['llmid'] = llmid + if pricing_type: + conditions.append('pricing_type = ${pricing_type}$') + params['pricing_type'] = pricing_type + if status: + conditions.append('status = ${status}$') + params['status'] = status + else: + conditions.append("status = 'active'") - where_clause = 'WHERE ' + ' AND '.join(conditions) if conditions else '' + where = 'WHERE ' + ' AND '.join(conditions) if conditions else '' + + count_sql = f"SELECT COUNT(*) as cnt FROM pricing_cache {where}" + offset = (page - 1) * page_size + data_sql = f""" + SELECT id, llmid, model_name, pricing_type, + input_price, output_price, unit_price, + currency, status, effective_from, effective_to, + cached_at + FROM pricing_cache + {where} + ORDER BY model_name + LIMIT {page_size} OFFSET {offset} + """ - # Count query - count_sql = f'SELECT COUNT(*) as cnt FROM pricing_cache {where_clause}' async with DBPools().sqlorContext(dbname) as sor: - count_rows = await sor.sqlExe(count_sql, params) - total = count_rows[0]['cnt'] if count_rows else 0 - result['total'] = total + count_result = await sor.sqlExe(count_sql, params) + if isinstance(count_result, list) and count_result: + result['total'] = count_result[0].get('cnt', 0) + elif isinstance(count_result, dict): + result['total'] = count_result.get('cnt', 0) - if total > 0: - data_sql = f""" - SELECT program_id, model, input_price, output_price, - unit, currency, updated_at - FROM pricing_cache - {where_clause} - ORDER BY program_id, model - LIMIT ${limit}$ OFFSET ${offset}$ - """ - rows = await sor.sqlExe(data_sql, params) - result['data'] = [dict(r) for r in (rows or [])] + data = await sor.sqlExe(data_sql, params) + if isinstance(data, dict): + result['data'] = data.get('rows', []) + elif isinstance(data, list): + result['data'] = data result['success'] = True - debug(f'Pricing query: returned {result["total"]} records') except Exception as e: - error(f'Pricing query failed: {e}') + error(f'query_pricing error: {e}') + result['error'] = str(e) + + return json.dumps(result, ensure_ascii=False, default=str) + + +async def get_pricing_by_llmid(llmid: str) -> str: + """Get all active pricing entries for a specific model.""" + result: dict[str, Any] = {'success': False, 'data': []} + + try: + env = ServerEnv() + dbname = env.get_module_dbname('sageapi') + + sql = """ + SELECT id, llmid, model_name, pricing_type, + input_price, output_price, unit_price, + currency, status, cached_at + FROM pricing_cache + WHERE llmid = ${llmid}$ AND status = 'active' + ORDER BY pricing_type + """ + + async with DBPools().sqlorContext(dbname) as sor: + data = await sor.sqlExe(sql, {'llmid': llmid}) + if isinstance(data, list): + result['data'] = data + result['success'] = True + elif isinstance(data, dict): + result['data'] = data.get('rows', []) + result['success'] = True + + except Exception as e: + error(f'get_pricing_by_llmid error: {e}') result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) diff --git a/sageapi/api/users.py b/sageapi/api/users.py index d0bd3b2..219d334 100644 --- a/sageapi/api/users.py +++ b/sageapi/api/users.py @@ -1,7 +1,6 @@ -"""User query API handler. +"""Users API handler. -Provides the RESTful endpoint for querying user information. -Reads from the local users_cache table synced from Sage. +Provides endpoint for querying cached user data. """ from __future__ import annotations @@ -11,73 +10,91 @@ from typing import Any from appPublic.log import debug, error from sqlor.dbpools import DBPools +from ahserver.serverenv import ServerEnv -async def query_users( - user_id: str | None = None, - keyword: str | None = None, - limit: int = 100, - offset: int = 0, -) -> str: - """Query user information from the local cache. - - Args: - user_id: Filter by specific user ID. - keyword: Search keyword (matches username, email, or phone). - limit: Maximum number of records to return. - offset: Number of records to skip. - - Returns: - JSON string with success flag and user data. - """ +async def query_users(keyword: str | None = None, orgid: str | None = None, page: int = 1, page_size: int = 50) -> str: + """Query users from cache with keyword search.""" result: dict[str, Any] = {'success': False, 'data': [], 'total': 0} try: - from ahserver.serverenv import ServerEnv env = ServerEnv() dbname = env.get_module_dbname('sageapi') - if not dbname: result['error'] = 'No database configured for sageapi module' return json.dumps(result, ensure_ascii=False, default=str) conditions = [] - params: dict[str, Any] = {'limit': limit, 'offset': offset} + params: dict[str, Any] = {} - if user_id: - conditions.append('user_id = ${user_id}$') - params['user_id'] = user_id if keyword: - conditions.append( - '(username LIKE ${keyword}$ OR email LIKE ${keyword}$ OR phone LIKE ${keyword}$)' - ) + conditions.append("username LIKE ${keyword}$") params['keyword'] = f'%{keyword}%' + if orgid: + conditions.append('orgid = ${orgid}$') + params['orgid'] = orgid - where_clause = 'WHERE ' + ' AND '.join(conditions) if conditions else '' + where = 'WHERE ' + ' AND '.join(conditions) if conditions else '' + + count_sql = f"SELECT COUNT(*) as cnt FROM users_cache {where}" + offset = (page - 1) * page_size + data_sql = f""" + SELECT id, username, orgid, orgname, email, phone, + status, created_at, updated_at, cached_at + FROM users_cache + {where} + ORDER BY username + LIMIT {page_size} OFFSET {offset} + """ - # Count query - count_sql = f'SELECT COUNT(*) as cnt FROM users_cache {where_clause}' async with DBPools().sqlorContext(dbname) as sor: - count_rows = await sor.sqlExe(count_sql, params) - total = count_rows[0]['cnt'] if count_rows else 0 - result['total'] = total + count_result = await sor.sqlExe(count_sql, params) + if isinstance(count_result, list) and count_result: + result['total'] = count_result[0].get('cnt', 0) + elif isinstance(count_result, dict): + result['total'] = count_result.get('cnt', 0) - if total > 0: - data_sql = f""" - SELECT user_id, username, email, phone, status, updated_at - FROM users_cache - {where_clause} - ORDER BY user_id - LIMIT ${limit}$ OFFSET ${offset}$ - """ - rows = await sor.sqlExe(data_sql, params) - result['data'] = [dict(r) for r in (rows or [])] + data = await sor.sqlExe(data_sql, params) + if isinstance(data, dict): + result['data'] = data.get('rows', []) + elif isinstance(data, list): + result['data'] = data result['success'] = True - debug(f'User query: returned {result["total"]} records') except Exception as e: - error(f'User query failed: {e}') + error(f'query_users error: {e}') + result['error'] = str(e) + + return json.dumps(result, ensure_ascii=False, default=str) + + +async def get_user_by_id(user_id: str) -> str: + """Get a single user by ID.""" + result: dict[str, Any] = {'success': False, 'data': None} + + try: + env = ServerEnv() + dbname = env.get_module_dbname('sageapi') + + sql = """ + SELECT id, username, orgid, orgname, email, phone, + status, created_at, updated_at, cached_at + FROM users_cache + WHERE id = ${user_id}$ + """ + + async with DBPools().sqlorContext(dbname) as sor: + data = await sor.sqlExe(sql, {'user_id': user_id}) + if isinstance(data, list) and data: + result['data'] = data[0] + result['success'] = True + elif isinstance(data, dict) and data.get('rows'): + result['data'] = data['rows'][0] + result['success'] = True + + except Exception as e: + error(f'get_user_by_id error: {e}') result['error'] = str(e) return json.dumps(result, ensure_ascii=False, default=str) diff --git a/sageapi/cache/__pycache__/__init__.cpython-310.pyc b/sageapi/cache/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fe27af49dcc2233abf1fa941a557ce51bfb6d58 GIT binary patch literal 118 zcmd1j<>g`k0+lA-ED-$|L?8o3AjbiSi&=m~3PUi1CZpdZ%3WeoI8wH!p5C@zh zN(}y)h)gEZ8;RYfYF6*!psd03T972E2~3nG*($Z))lz9CFGit3(|ackui@>!p6uXN zkZPm58PeS8$3x>a(pwJ|#nw4A=SU7sJu9D+61ieN1 zf;=XVqqihqlqcllsFhVkF3FSfiJRUYU$4q#`IHpsuc z(l#UsK*T#jZ9{^YP@i-nh*LmKIs$HjL<+64j-o+harTLwO$0k4j@pqC+fiV*Yk5F3 zO-pfi@p;DXE^)^4T08i}AF|9{s5q}l)e3+Q-4wGka1I zwJerEA!Mqu6%>oo)l36JnDN8&aHUr(mU3 z2sS`A9F=TL7_9alwMscV#yYlfu2ImBLdqI$p5>m2+DaFxTBCJ*lLEc6;pxYS<`@kW z>^bwwz+n8qSgZ_+BShfB0tG4=p`u)@a)&Q|J@3^4jCrd6vtD!f>A&|DXCQ(}tNIDd z&;=B^3-h>Ng~5z4Lbw<`B~->3`4bw3-Dae5$-7)v`fdUP*4sn$xZ6yh zMvptubl+2SMf~p}PY^X{*>VwQmxcUJCRXs z3#3MAf}MeW1x}SA|whD1ZakX ztMEo+=;Z}VVUf1a2ST9GHE~D!1vJ<2xABQEROP3>?j4#rvSsqXX~*%G0;93#+#|=& zdrJew4RqSo8e0=dx4nVHFl`+?43zT>ZHYXrevyjV9U}ThJ@p|5GFq|f_8ytp9F5+e zm$1U0AaE`T1kR6U_Bz70JIctdW{`gvew*K6zhuVw1#5B2&~KJ82KRS|U1WRE)h-{q zu<HI%^UCl5~Gxu))w9>CpeRwQtU2kuGl+4cD^~D(O z$T&pfBxzc0ln1yZ4~ZP7+aAQ!qK%i?00ZP7t@j*SY+f4j9~|Lj8n!{SKmyD@Mmr^5VYfm3xBah@__gZ4`UGOJjdx%`)3b!ey;u_!l{V{>W0N z9Nh&NFouyF*~Dd=3`Ry-Col+BTHM%Di6BPEPQgc*U?WDxz7-^!Dw}w{86*+vDOG@N z^LvzPY!Z?xbe`L|k>24N3Eyv=>*Q;AuRz?-0xwmppiM>*b>Go1VP4L!9kj^mN(}IZ zX}fdYfZ`slb%OQ2I|HJLpY$hNp90mG#r;vaiUjfr@U92@H(r{p-;a`#jN!7 z>*)KG`pI^_S@{!Q4^zH|^|9WgVnQ`mxxPru-=t!8<$PLilZiV-`9os&joO+J!@*Fr z))f?u;=oPSsDmze+ScZ%M(F?|Dy8=NI2E>+>`5=sP`OXx@ zuuhAQ`j@G`KsU)GXe-~(tG?e(WjCgJ-S@9|gLpVn@_m_xzORqcx=&Godp6V7Iw;EL zWqb_iC~bE15)GWCzB+l7C~PaUl}dey`roEvB5_+Ir>p5+K~XxRgtp8)UN2RP^~b7@ zRclVM&a0lYQ0GodU!e!V5SW0KPxNM$)z+bR~*)+#bsSgZ^_KdeHml-%0=J@klgVDwk`XLQLcyG6tj zO?@MMkO&%!`dz$86|FA5Pe2u#JfO|}^IDj~mm>(`L)74#LdDpxH`vACvqI7Abf`hz zIiCwUo%gyMaTGS=baUXvXc4>5e8KmpzH4C}+RMW>o|jh!0yt0EfQ^YoRxg#EIwB%| L&b(Xi&b$8x2Z$Pj literal 0 HcmV?d00001 diff --git a/sageapi/init.py b/sageapi/init.py index 0c02353..5994414 100644 --- a/sageapi/init.py +++ b/sageapi/init.py @@ -2,40 +2,69 @@ Registers all public functions to ServerEnv so they are accessible from dspy scripts and other modules via the global environment. +Also sets up route registration and database event bindings. """ -from appPublic.log import debug +from appPublic.log import debug, info from sqlor.dbpools import DBPools from ahserver.serverenv import ServerEnv # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- -from .auth.dapi_auth import dapi_auth_middleware -from .auth.uapi_sign import uapi_sign_verify +from .middleware.dapi_auth import authenticate_request, DapiAuthMiddleware # --------------------------------------------------------------------------- # Sync # --------------------------------------------------------------------------- -from .sync.base_sync import BaseSync -from .sync.user_sync import sync_users -from .sync.pricing_sync import sync_pricing -from .sync.uapi_sync import sync_uapi -from .sync.llmage_sync import sync_llmage +from .sync.base_sync import BaseSync, run_all_syncs +from .sync.user_sync import UserSync +from .sync.pricing_sync import PricingSync +from .sync.uapi_sync import UapiSync +from .sync.llmage_sync import LlmageSync + +# Module-level convenience functions for sync +async def sync_users() -> str: + """Convenience: run user sync.""" + syncer = UserSync() + return await syncer.sync() + +async def sync_pricing() -> str: + """Convenience: run pricing sync.""" + syncer = PricingSync() + return await syncer.sync() + +async def sync_uapi() -> str: + """Convenience: run uapi sync.""" + syncer = UapiSync() + return await syncer.sync() + +async def sync_llmage() -> str: + """Convenience: run llmage sync.""" + syncer = LlmageSync() + return await syncer.sync() # --------------------------------------------------------------------------- # Cache # --------------------------------------------------------------------------- from .cache.cache_manager import CacheManager +# Global cache instance (per-process) +_cache_manager = CacheManager(max_entries=10000, default_ttl=300) + # --------------------------------------------------------------------------- # API # --------------------------------------------------------------------------- -from .api.balance import get_customer_balance +from .api.balance import get_customer_balance, update_customer_balance from .api.accounting import create_accounting_record, query_accounting_records -from .api.users import query_users -from .api.pricing import query_pricing -from .api.health import health_check +from .api.users import query_users, get_user_by_id +from .api.pricing import query_pricing, get_pricing_by_llmid +from .api.health import health_check, readiness_check + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- +from .router import Router, setup_routes # --------------------------------------------------------------------------- # Utils @@ -45,66 +74,79 @@ from .utils.crypto import encrypt_payload, decrypt_payload def _bind_sageapi_events(dbpools: DBPools, dbname: str) -> None: - """Bind database events to SageAPI cache invalidation handlers. - - When sync state or accounting records change in the database, - the corresponding cache entries are invalidated automatically. - """ + """Bind database events to SageAPI cache invalidation handlers.""" bindings = [ - # sync_state table: clear sync-related caches on change - (f'{dbname}:sync_state:c:after', CacheManager.invalidate_sync_state), - (f'{dbname}:sync_state:u:after', CacheManager.invalidate_sync_state), - (f'{dbname}:sync_state:d:after', CacheManager.invalidate_sync_state), - # accounting_records: clear accounting cache on change - (f'{dbname}:accounting_records:c:after', CacheManager.invalidate_accounting), - (f'{dbname}:accounting_records:u:after', CacheManager.invalidate_accounting), - (f'{dbname}:accounting_records:d:after', CacheManager.invalidate_accounting), + (f'{dbname}:sync_state:c:after', _cache_manager.invalidate_sync_state), + (f'{dbname}:sync_state:u:after', _cache_manager.invalidate_sync_state), + (f'{dbname}:sync_state:d:after', _cache_manager.invalidate_sync_state), + (f'{dbname}:accounting_records:c:after', _cache_manager.invalidate_accounting), + (f'{dbname}:accounting_records:u:after', _cache_manager.invalidate_accounting), + (f'{dbname}:accounting_records:d:after', _cache_manager.invalidate_accounting), ] for event_name, handler in bindings: - dbpools.bind(event_name, handler) - debug(f'SageAPI event bound: {event_name}') + try: + dbpools.bind(event_name, handler) + debug(f'SageAPI event bound: {event_name}') + except Exception as e: + debug(f'SageAPI event bind skipped: {event_name} ({e})') def load_sageapi() -> None: """Register all SageAPI functions into ServerEnv. Called by the Sage server during module loading phase. - All registered functions become available as globals in dspy scripts. """ env = ServerEnv() # Auth - env.dapi_auth_middleware = dapi_auth_middleware - env.uapi_sign_verify = uapi_sign_verify + env.authenticate_request = authenticate_request + env.DapiAuthMiddleware = DapiAuthMiddleware # Sync env.sync_users = sync_users env.sync_pricing = sync_pricing env.sync_uapi = sync_uapi env.sync_llmage = sync_llmage + env.run_all_syncs = run_all_syncs env.BaseSync = BaseSync + env.UserSync = UserSync + env.PricingSync = PricingSync + env.UapiSync = UapiSync + env.LlmageSync = LlmageSync # Cache - env.cache_manager = CacheManager() + env.cache_manager = _cache_manager # API env.get_customer_balance = get_customer_balance + env.update_customer_balance = update_customer_balance env.create_accounting_record = create_accounting_record env.query_accounting_records = query_accounting_records env.query_users = query_users + env.get_user_by_id = get_user_by_id env.query_pricing = query_pricing + env.get_pricing_by_llmid = get_pricing_by_llmid env.health_check = health_check + env.readiness_check = readiness_check + + # Router + router = Router() + setup_routes(router) + env.sageapi_router = router + info(f'SageAPI: {len(router.get_routes())} routes registered') # Utils env.SageHttpClient = SageHttpClient env.encrypt_payload = encrypt_payload env.decrypt_payload = decrypt_payload - # Bind database events for automatic cache invalidation + # Bind database events dbpools = DBPools() dbname = env.get_module_dbname('sageapi') if dbname: _bind_sageapi_events(dbpools, dbname) - debug(f'SageAPI event listeners bound for database: {dbname}') + info(f'SageAPI: event listeners bound for database: {dbname}') else: - debug('SageAPI event listeners skipped: no database configured for sageapi module') + debug('SageAPI: event listeners skipped (no database configured)') + + info('SageAPI module loaded successfully') diff --git a/sageapi/middleware/__init__.py b/sageapi/middleware/__init__.py new file mode 100644 index 0000000..93fa6f7 --- /dev/null +++ b/sageapi/middleware/__init__.py @@ -0,0 +1 @@ +# Middleware package diff --git a/sageapi/middleware/__pycache__/__init__.cpython-310.pyc b/sageapi/middleware/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4b369c391b5707a0f6ff22549ea059b11555cc85 GIT binary patch literal 123 zcmd1j<>g`kf?b`wS)xGtF^Gc<7=auIATDMB5-AM944RC7D;bJF!U*D*TybK0YGOgA ter{$;N=|BdVo|Doe0*kJW=VX!UP0w84x8Nkl+v73JCMF&CLqDW006QA7(f63 literal 0 HcmV?d00001 diff --git a/sageapi/middleware/__pycache__/dapi_auth.cpython-310.pyc b/sageapi/middleware/__pycache__/dapi_auth.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..853acdcb4ef36763d73300e5d51aaa1b9cb39170 GIT binary patch literal 6896 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 Q<$c|_#hSAk$Ett*|1=)r-2eap literal 0 HcmV?d00001 diff --git a/sageapi/middleware/dapi_auth.py b/sageapi/middleware/dapi_auth.py new file mode 100644 index 0000000..83ec06f --- /dev/null +++ b/sageapi/middleware/dapi_auth.py @@ -0,0 +1,254 @@ +""" +DAPI Authentication Middleware for sageapi. + +Authenticates incoming requests using DAPI signature headers by querying +the Sage downapikey table. + +Usage with FastAPI / Starlette: + from sageapi.middleware.dapi_auth import DapiAuthMiddleware + app.add_middleware(DapiAuthMiddleware) + + # Or for specific routes, use the get_dapi_key() dependency. +""" + +import hashlib +import hmac +import time +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Optional + +from starlette.datastructures import Headers +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.requests import Request +from starlette.responses import JSONResponse + +# Headers expected from the client +HEADER_API_KEY = "X-DAPI-Key" +HEADER_TIMESTAMP = "X-DAPI-Timestamp" +HEADER_SIGNATURE = "X-DAPI-Signature" + +# Default timestamp tolerance: 5 minutes +DEFAULT_TIMESTAMP_TOLERANCE_SEC = 300 + + +@dataclass +class DapiKeyRecord: + """Represents a record from the downapikey table.""" + + id: Any + apikey: str + secret: str + status: str + expire_date: Any # datetime or string + description: str = "" + + @property + def is_active(self) -> bool: + return self.status == "active" + + @property + def is_expired(self) -> bool: + """Check if the key has expired based on expire_date.""" + from datetime import datetime, timezone + + if self.expire_date is None: + return False + # Handle both datetime objects and ISO string + if isinstance(self.expire_date, str): + try: + expire_dt = datetime.fromisoformat(self.expire_date.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return False + else: + expire_dt = self.expire_date + + # Ensure expire_dt is timezone-aware (UTC) + if expire_dt.tzinfo is None: + expire_dt = expire_dt.replace(tzinfo=timezone.utc) + + now = datetime.now(timezone.utc) + return now > expire_dt + + +class DapiAuthError(Exception): + """Raised when DAPI authentication fails.""" + + def __init__(self, status_code: int, detail: str): + self.status_code = status_code + self.detail = detail + super().__init__(detail) + + +def compute_dapi_signature(method: str, path: str, timestamp: str, secret: str, body: Optional[bytes] = None) -> str: + """ + Compute the DAPI HMAC-SHA256 signature. + + The signed string is: "{method}\n{path}\n{timestamp}\n{body_hash}" + where body_hash is SHA-256 hex digest of the request body (or empty string if no body). + """ + if body: + body_hash = hashlib.sha256(body).hexdigest() + else: + body_hash = "" + + string_to_sign = f"{method}\n{path}\n{timestamp}\n{body_hash}" + + signature = hmac.new( + secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + return signature + + +def verify_timestamp(timestamp_str: str, tolerance_sec: int = DEFAULT_TIMESTAMP_TOLERANCE_SEC) -> bool: + """Verify that the timestamp is within the allowed window.""" + try: + ts = float(timestamp_str) + except (ValueError, TypeError): + return False + + now = time.time() + return abs(now - ts) <= tolerance_sec + + +async def lookup_api_key(api_key: str) -> Optional[DapiKeyRecord]: + """ + Look up an API key in the Sage downapikey table. + + Returns a DapiKeyRecord if found, None otherwise. + """ + from ahserver.serverenv import ServerEnv + from sqlor.dbpools import get_sor_context + + env = ServerEnv() + async with get_sor_context(env, "dapi") as sor: + recs = await sor.R("downapikey", {"apikey": api_key}) + + if not recs: + return None + + rec = recs[0] # Take the first match + return DapiKeyRecord( + id=rec.get("id"), + apikey=rec.get("apikey", ""), + secret=rec.get("secret", ""), + status=rec.get("status", "inactive"), + expire_date=rec.get("expire_date"), + description=rec.get("description", ""), + ) + + +async def authenticate_request( + request: Request, + tolerance_sec: int = DEFAULT_TIMESTAMP_TOLERANCE_SEC, +) -> DapiKeyRecord: + """ + Authenticate a request using DAPI headers. + + Returns the DapiKeyRecord on success. + Raises DapiAuthError on failure. + """ + headers = request.headers + + # 1. Check required headers + api_key = headers.get(HEADER_API_KEY) + if not api_key: + raise DapiAuthError(401, f"Missing header: {HEADER_API_KEY}") + + timestamp_str = headers.get(HEADER_TIMESTAMP) + if not timestamp_str: + raise DapiAuthError(401, f"Missing header: {HEADER_TIMESTAMP}") + + signature = headers.get(HEADER_SIGNATURE) + if not signature: + raise DapiAuthError(401, f"Missing header: {HEADER_SIGNATURE}") + + # 2. Validate timestamp window + if not verify_timestamp(timestamp_str, tolerance_sec): + raise DapiAuthError(401, "Request timestamp is outside the allowed window") + + # 3. Look up the API key in the database + key_record = await lookup_api_key(api_key) + if key_record is None: + raise DapiAuthError(401, "Invalid API key") + + # 4. Check key status + if not key_record.is_active: + raise DapiAuthError(403, "API key is inactive") + + # 5. Check expiration + if key_record.is_expired: + raise DapiAuthError(403, "API key has expired") + + # 6. Read request body for signature verification + body = await request.body() + + # 7. Compute expected signature and compare + expected_signature = compute_dapi_signature( + method=request.method, + path=request.url.path, + timestamp=timestamp_str, + secret=key_record.secret, + body=body if body else None, + ) + + if not hmac.compare_digest(signature, expected_signature): + raise DapiAuthError(401, "Invalid signature") + + return key_record + + +class DapiAuthMiddleware(BaseHTTPMiddleware): + """ + Starlette/FastAPI middleware that enforces DAPI authentication on all requests. + + Attributes: + exclude_paths: List of path prefixes to skip authentication (e.g., ['/health', '/docs']). + tolerance_sec: Timestamp tolerance in seconds (default: 300). + """ + + def __init__( + self, + app: Any, + exclude_paths: Optional[list[str]] = None, + tolerance_sec: int = DEFAULT_TIMESTAMP_TOLERANCE_SEC, + ): + super().__init__(app) + self.exclude_paths = exclude_paths or [] + self.tolerance_sec = tolerance_sec + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> JSONResponse: + # Skip excluded paths + for prefix in self.exclude_paths: + if request.url.path.startswith(prefix): + return await call_next(request) + + try: + key_record = await authenticate_request(request, self.tolerance_sec) + # Attach the key record to request state for downstream use + request.state.dapi_key = key_record + request.state.dapi_key_id = key_record.id + except DapiAuthError as e: + return JSONResponse( + status_code=e.status_code, + content={"error": e.detail}, + ) + + return await call_next(request) + + +def requires_dapi_auth( + request: Request, + tolerance_sec: int = DEFAULT_TIMESTAMP_TOLERANCE_SEC, +) -> Awaitable[DapiKeyRecord]: + """ + Dependency function for FastAPI route-level DAPI authentication. + + Usage: + @app.get("/protected") + async def protected_route(key: DapiKeyRecord = Depends(requires_dapi_auth)): + ... + """ + return authenticate_request(request, tolerance_sec) diff --git a/sageapi/router.py b/sageapi/router.py index b926481..b13b301 100644 --- a/sageapi/router.py +++ b/sageapi/router.py @@ -29,10 +29,10 @@ class Router: Args: method: HTTP method (GET, POST, PUT, DELETE). - path: URL path pattern, e.g. '/api/v1/balance'. + path: URL path pattern. handler: Callable that handles the request. auth: Authentication method ('dapi', 'uapi', 'none'). - description: Human-readable description of the endpoint. + description: Human-readable description. """ self._routes.append({ 'method': method.upper(), @@ -55,64 +55,75 @@ class Router: return None -# Global router instance -router = Router() +def setup_routes(router: Router) -> None: + """Register all SageAPI routes. + Health endpoints (no auth): + GET /api/v1/health + GET /api/v1/health/ready -def register_routes() -> None: - """Register all SageAPI API routes. + Balance endpoints (dapi auth): + GET /api/v1/balance + POST /api/v1/balance/update - Called during module initialization to populate the router - with all available endpoints. + Accounting endpoints (dapi auth): + POST /api/v1/accounting + GET /api/v1/accounting + + Users endpoints (dapi auth): + GET /api/v1/users + GET /api/v1/users/{user_id} + + Pricing endpoints (dapi auth): + GET /api/v1/pricing + GET /api/v1/pricing/model/{llmid} """ - from .api.health import health_check - from .api.balance import get_customer_balance - from .api.accounting import create_accounting_record, query_accounting_records - from .api.users import query_users - from .api.pricing import query_pricing + # Health (no auth) + from sageapi.api.health import health_check, readiness_check + router.register('GET', '/api/v1/health', health_check, auth='none', description='Health check') + router.register('GET', '/api/v1/health/ready', readiness_check, auth='none', description='Readiness check') - # Health check (no auth required) - router.register( - 'GET', '/api/v1/health', - handler=health_check, - auth='none', - description='Health check endpoint', - ) - - # Customer balance - router.register( - 'GET', '/api/v1/balance', - handler=get_customer_balance, - auth='dapi', - description='Query customer balance', - ) + # Balance + from sageapi.api.balance import get_customer_balance, update_customer_balance + router.register('GET', '/api/v1/balance', get_customer_balance, auth='dapi', description='Query customer balance') + router.register('POST', '/api/v1/balance/update', update_customer_balance, auth='dapi', description='Update customer balance') # Accounting - router.register( - 'POST', '/api/v1/accounting', - handler=create_accounting_record, - auth='dapi', - description='Create an accounting record', - ) - router.register( - 'GET', '/api/v1/accounting', - handler=query_accounting_records, - auth='dapi', - description='Query accounting records', - ) + from sageapi.api.accounting import create_accounting_record, query_accounting_records + router.register('POST', '/api/v1/accounting', create_accounting_record, auth='dapi', description='Create accounting record') + router.register('GET', '/api/v1/accounting', query_accounting_records, auth='dapi', description='Query accounting records') # Users - router.register( - 'GET', '/api/v1/users', - handler=query_users, - auth='dapi', - description='Query user information', - ) + from sageapi.api.users import query_users, get_user_by_id + router.register('GET', '/api/v1/users', query_users, auth='dapi', description='Query users') + router.register('GET', '/api/v1/users/detail', get_user_by_id, auth='dapi', description='Get user by ID') # Pricing - router.register( - 'GET', '/api/v1/pricing', - handler=query_pricing, - auth='dapi', - description='Query pricing information', - ) + from sageapi.api.pricing import query_pricing, get_pricing_by_llmid + router.register('GET', '/api/v1/pricing', query_pricing, auth='dapi', description='Query pricing') + router.register('GET', '/api/v1/pricing/model', get_pricing_by_llmid, auth='dapi', description='Get pricing by model ID') + + # Admin (dapi auth with admin role) + from sageapi.sync.base_sync import run_all_syncs + router.register('POST', '/api/v1/admin/sync', run_all_syncs, auth='dapi', description='Trigger full sync') + router.register('GET', '/api/v1/admin/sync/status', _sync_status, auth='dapi', description='Sync status') + + +async def _sync_status() -> str: + """Return current sync status for all entities.""" + import json + from sqlor.dbpools import DBPools + from ahserver.serverenv import ServerEnv + + result = {'success': False, 'data': []} + try: + env = ServerEnv() + dbname = env.get_module_dbname('sageapi') + sql = "SELECT entity_type, sync_status, last_sync_time, error_msg FROM sync_state ORDER BY entity_type" + async with DBPools().sqlorContext(dbname) as sor: + rows = await sor.sqlExe(sql) + result['data'] = rows if isinstance(rows, list) else rows.get('rows', []) + result['success'] = True + except Exception as e: + result['error'] = str(e) + return json.dumps(result, ensure_ascii=False, default=str) diff --git a/sageapi/sync/__init__.py b/sageapi/sync/__init__.py index e69de29..b141bd3 100644 --- a/sageapi/sync/__init__.py +++ b/sageapi/sync/__init__.py @@ -0,0 +1,16 @@ +"""SageAPI sync engine package.""" + +from .base_sync import BaseSync, run_all_syncs +from .user_sync import UserSync +from .pricing_sync import PricingSync +from .uapi_sync import UapiSync +from .llmage_sync import LlmageSync + +__all__ = [ + 'BaseSync', + 'run_all_syncs', + 'UserSync', + 'PricingSync', + 'UapiSync', + 'LlmageSync', +] diff --git a/sageapi/sync/__pycache__/__init__.cpython-310.pyc b/sageapi/sync/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8c7066264f2aa315f5ffa6cac5ebd232e0fadc69 GIT binary patch literal 457 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 MnNybW2Q!=h3v7L0WdHyG literal 0 HcmV?d00001 diff --git a/sageapi/sync/__pycache__/llmage_sync.cpython-310.pyc b/sageapi/sync/__pycache__/llmage_sync.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0d967accbea405927489d0d18b93fb77daf4f92 GIT binary patch literal 6337 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 hE<aW$r4OmuvD><^N8%hq3?w literal 0 HcmV?d00001 diff --git a/sageapi/sync/__pycache__/pricing_sync.cpython-310.pyc b/sageapi/sync/__pycache__/pricing_sync.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af7c7811ea5935504b962d1b0e1ea7ce65430bc1 GIT binary patch literal 5282 zcmahNO>f)Cb%vxwNwyPj;%1vgHk}s9CMpuS-Q&VB;t!>E>d1y=cd;r`g5iv9diCWY z1>35$$kw?OJrq3`3)sj0ge+WVL1M zP}&W)$VNsi-)#U6JCLmgtJ;Txt*~|(?Ah{A_#q+oI3N#8jt~ll3*Ul>NTsMNW8HOpWo)^guX5XM-)%MR`nMFI zMXGYEwkMET2dAf>40N8u>m5TQ2*{Y!G)`+e*J=jWYZ-3TvOH5Wc~*dI@1jHtV&*P6 zFnEsV-x;-8UI<6H$!B16me0ZHTrwKs0pte136L9n26Qp6W>Tt9=0H5k@Ib!G#MW-R zYSx2IgJuGyx&Hxn)J@NAIs#=SJl}4#RaOeWBb$y697InRK1_K30uQxSr^mU%;(PILT?BL(*>Cnr4WA=?6y`(+EC0E)e;rR{H&-5~Kkr(*P zJJQRnlM7lUhXh!f?fxFL8a^+C?;J7Ms1yy+^zAz1wr__PG-3(NZt_v<7+69nruVYt zHa(aXTBzGRXt7h*KVqR}E_z^qyi*s!DhXEi!_PO$yJgmHmpX0OfZ&$xvt{vH@^JuB5{o0iL;OhOrOVKwcBxORfm z;@3~`&)btHfMNI@Z}P4}*u;go@Eqv|zB3UH+Khx4(oQj(z$T)m;AYj>TK=y(6p`ci#_&od$Yp2yQ6alPKaJIgo7K{t_+O-S>QyG zNxOV#W_NR_yS=+!-es#_u(j=#t#Wm({CLPQpCHM^jEZcfx)$SC91P{r1O7=cOIG3a z7N9vKJ@Pu+r#|V?3;GA`1oGXVF!CE5(n3*FTInZ>N;wa6gXX(?gAluj5q2>W6Lw1N zb5&TXLn3injrQ^~u7L|KknT@+lJr?Rz!+zxVq9sd-g4}^w+tT{ue7>VvUWKf?WCqrEjU z3)Ijw&A4U!+qiA~OZ#5#pSSMbq1}(K-M734*p;FDk%N&Lc!T`$6?}Y+#fMfWech+N z*3&P^6-53E3i&}>C$FDERxo-Qt}j7B($98hAZzHoY%ddJ4Q+!^@;N!VX%OEyU!tTB z#Y*>vpSgt8+9Sv2ajuuS%=7FO?U`2wCBK0j)i=K~Pw6RqL2fa-zl%*_f2UgB-Gk&Z zT7s|+q{c(GSqb2!QVGp;eSc?bb8Tg>%zj?}g6;3D!)KBq_KpNYEw?<^7wk)M&RnQm z5_5i%M4DoPCD9fuDv#C@

`zl5M=fFu<{BBb$}I?Kr`O#mwWexrmcqHu?^dL<|Ws zLfo4i{?n8OA%&#_dyJmA8hBoqS*;|RjK?-4oWxz0im@z?r)+xcMD%p*L|S2-jHpJt z9W3&`y~NTV6K0Y9Vr6T;TxFBx{NUFSyDslPc*2rCN_%(&B|bcYQiqYTMUlURQpih) znUel6sYCF zZFCweamna79v-SeFQF|!mJUkKQbpa0)RG{n55_G>khSplk#^^k z`=18z;;b?)i?Wvpa9JcJk|S6HC4T0vXT{|~x^e&7HA literal 0 HcmV?d00001 diff --git a/sageapi/sync/__pycache__/uapi_sync.cpython-310.pyc b/sageapi/sync/__pycache__/uapi_sync.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3eb0cb11f8b51f25c7ea6a08b221010588869761 GIT binary patch literal 5266 zcmai2&2QVt6`vs~k(TYm-MU5{>;|*lxUqmlDNvw?z)9C1%G#-82bSH%st5>%Gq#!4 zmxoksYpEbx=h9n?9*YIyWB-L-`&X2E>}lH`d)h^dzBi;u$r8O3IGUL^zc+8*d-LWE z+q1JJ0?%K6Kl{r6AtC?3#_(f6<70UJr%-TGC!BJvLF*L$^@i5e>w43u8_hz!KnXW^ zp;2s_b(0c7-&tbnlGaO~5MJcwTf$9YUg-5{uGL7{>iwZ~u=;$fcHVN?eWw1pPRC;p zShwSJI_yxko2=#>3AVAuf;K~6|0NV((omDL9LhfvuBlT0s?gV4 z8aMhnFNE|J0ZK9XH$xp-)>mfV2h5IkPmo?ProH$VS`-I8aX1c zl9}FXFkJjR5rKQmKu{@~q7^s|#+|^4-2#`woEA@9C3b^K3Vqu4T0R5kVq6@0Ee{Ro zvr{iPX6RW`9D!w}gWZa0hU_hTx>?<;vTmi@f$0Rdoq#=NcY6zW+3p@pccuSZdN(4@ z*BT+|x`QL=fhxOPc%cPf}|Wf#6-8vCmAUw2Irl8VveR`Wc#!w!Sy~F zY3}ZARQK50XKa0Ub-P+yuPzVoy?ekd^MNv3t*xi%9Ty|=;DIej@T{uA>(4`RL_+eS z*rx#r=>`3xb^@o)Uoe0h9HM(!i?qtmA}Zw^j8(r|4Ab82kn!fqkJf){lgCoN1y-hvf;CZdOw-0B;NP57!aJ(I{ ztsOefB#r8G;Z7X0>M5P=gfnwLU{lLgfuji zGlWFn+lfzBbxJsYg2OVEY!Dz5A+~n*cZU)fwmdcv0qJ~c)Q98BvsH{&wIrgedT#ti zP6$(RCFYq#1ol0FIF%vWajSMNYQ22MsRjFY##C7z>&e;GW8-2ci}NU~$Iqk0Czc21E>AWelF|MH9D_qG_9lfy z(lyA~R|eP-WK6mSoA}ivc@xq#V6@L*+AAU>DF->^n^&hDBISTbqKkrO>m7FS9QYOg z1_mko3h6oq*8iY zq^YLU&71=r-rX@eg$|RhMQ3&xHnECN87YTD8X?9-Pf4SZqW9C^Ecq{32d_JRzz!X+ zAvim1%fWH`Xvu%bmi)+6Ugq&~DKhY845?QjBOQ{9$P{P7?FJ&6s-C$*eGiC=jrP%z zkdb-n$QI-q(UgrjoA29pWZCMn05u&xJd`c@V@MX{A{LlnNruI3C?Xv)yQl=WRUrc# zQ#~rKCV6He`YMd~e*%T}#wyHFOS7m!wT~9)4g9Bb)cBufSZ}oNjKBBh(iATNyO^*J z9=zG(w~BRm{XHl`>Kh#H;GFigkU|o78}9R%%q>Dv2MJziTvBysUkc%SMwj>>1~e>O zP$;zv`ne&O;&xvMf)66Kr99NGA2?&>*G)?^ukmmfk=F2*`W^@3^OHVMXq&sm^h$mSv~7|WgK!7_F{ zF#8a43J8Az3PUqMs}}r9^nEC`H`;#2bi(#ldjXi zoF7`#&vrKswyXBe>a%JzTiZR@Td&$1YpOZ3zPi4dwx(+PkfGX;tVX4^)&2ENyS7!Y zO1$By1xx%?Bhjkz11#=i@pCNF`%GNJi-N@OIH(Q(Jt!=KYm0xkw1vC3f1&8*naH$l z-gZGw6P+8Kx(ERX{^N<%lqf+o^%A~;smqZ>XNsmk9ouR@C=JLZ=u}K6NfTKsv3ee3 T4(;Qm#~@Z|p3Z0`ZC?LBH`hJ8 literal 0 HcmV?d00001 diff --git a/sageapi/sync/__pycache__/user_sync.cpython-310.pyc b/sageapi/sync/__pycache__/user_sync.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..488f303ee3098550cd082461d4da7537b0136c34 GIT binary patch literal 5762 zcmai2&2QVt73YwnPs>TxakHE4ZaQ0}jkR!<06nx#lXPuaiM@5Kz_PoIfCa&DXq%}d z%0mjVRTnsYG9BsRfd*oG$#YWH*uE!$hMav7>N;0l| zzsO*AIaEHWsYf2ws^%5 zX?;zgw@l*vx)XZ1C>Mv1UKx1#1peVmXgmWHX*5kvnie;klv~XVr_C(SG;=)bfdX%$ z+KS%7P2+&_JTJVa%@Qvv3eWKcpqBU|P!}~-;h^UVzY0B9_yQ>WTAUy1JI+Xj#54qL z#yM=At{)dekk^)7{(oQ{@zv1pxSk#PZ7+<=`2Q9Da!tP> z15+$=X#Z$%Q?}NZnKP?na(ZU*%z(T!peLK$bICnFuz*_lvT#NRMlzHP(4T#X=rz-|HbR7X!G=)RjF@=#hytFrp(bSCrfpHo3P^O_&k}!R4 z93Gu^*W^Evfqm{0eWNYG>Z9U$tKV|*zRHSRM-dLaZp8k)Mtzv=C z>N{Y&zz1SG>h&SeKwYzt50jyZg8>dFNcUcmlX;}x$0q7O8GEhoZq#F z)!NFaYp#M^+Dyw}9@P}R<6=Y~Uf`kzX2b^k!)0g=je+rb_KZZvfSi-xo5zsb{)7pD za>x#4Gd3&tVj{#f7%Q7^ZYJUOE=JqCs6HE?D&5n#_@D*J1j0@C(L-c_+?I`g1wxX{ zla&LEarQ`zt2}50uG0!1!NU*=Otbt2npK<)k6X19ubhQY@3jtMD-47v!K`uV)sZJW z+if`@0nm?IaZY${Ab6-{6}ZCqoe8erM&WKjV}3z1%Y>SyNw3j=(|72<=o0;>c|HG+ zrFU+G%_L*ncO@lZ>$SQ0q103aq9u-{S6(^ zfhCg#-87&I$w0!e-eh?8f^ga+;(mWYjsmJ4^_g*OQ6tKp{)7OA7|-GT75q7c#FFca z)B^UOHEO$ikXbI*94huO_BasCgEE9E1>R?oIA#0KHrDoP?7$ac^i$>^uui~4@G4}^ z!GY&ON?2uEJ2vC?(cy%Fhu!}5kal)^GDsjs zW&nIhF2qRMEC5bukA#*XlY}-GrkyO6&DONJP#uOeRMTcbb+l2-Lt#_eTR^EMM?gq> z3oOkKBe0Y<2b-oElr+HtZ5|?(a0Kxd2}kmW=7K3?9-oojP9;;cc__U*nv5eC%+V~TMR?u`*YP`cLR+S6G@+UR4nP||Agle2L2}PS# z9NCB8Ols5LCZEsaE z1^vACJjq5A13cZM{d={ONH{K>s2zE%?VR}SUYqGnQRgyFtCbYZ zVPz-2Diy59#8=P+^n2M9-ve>QN6?frN}W=Z*%q8Uz?NKxj@XPQzF?MND{M7JA7=d) z28T<~P?Hi$@?@EO04;pdkp73M0;eI%SL3`bUBR}|0NMfXwLEw(+4gbIX`%PP+rrcO z#{PEA-dTHEix(U9{oQKK-dLBfrRrLBGkq&G_8?H(5UAtg`r2M~({5}vYXUb(E>_@q zo4`F3A7b+{Hg~Z}KP%M}ey@ofH25GFmp5>6>i52R0G(_D#1rBkzGo2+FUWVAKym(o($FLPk`D3n PlN$MjWl}PW=CbvFd$7tr literal 0 HcmV?d00001 diff --git a/sageapi/sync/base_sync.py b/sageapi/sync/base_sync.py index 787c0b6..b9d070b 100644 --- a/sageapi/sync/base_sync.py +++ b/sageapi/sync/base_sync.py @@ -1,125 +1,248 @@ -"""Base synchronization class for SageAPI. - -Provides the foundation for all data sync workers. Handles common -concerns: checkpoint management, retry logic, batch processing, -and error reporting. """ +BaseSync - Abstract base class for all Sage data sync modules. -from __future__ import annotations - +Provides: +- Checkpoint management (read/write sync_state table) +- Batch processing with configurable size +- Retry logic with exponential backoff +- Common sync flow orchestration +""" import time +import logging from abc import ABC, abstractmethod -from typing import Any +from typing import Any, Dict, List, Optional -from appPublic.log import debug, error, info +from sqlor.dbpools import get_sor_context +from ahserver.serverenv import ServerEnv + +logger = logging.getLogger(__name__) class BaseSync(ABC): - """Abstract base class for data synchronization workers. + """ + Abstract base class for incremental sync from Sage DB to local cache. - Each concrete sync subclass implements the data fetch and - persist logic for a specific upstream data source. + Subclasses must implement: + - fetch_incremental(sor, since_timestamp): fetch delta from Sage DB + - persist(sor, records): UPSERT records into local cache table + - get_latest_timestamp(records): extract max updated_at from records """ - def __init__(self, sync_name: str, batch_size: int = 500) -> None: - self.sync_name = sync_name - self.batch_size = batch_size - self._last_checkpoint: dict[str, Any] = {} + # --- subclass overrides --- + MODULE_NAME: str = "" # Sage module name (e.g. 'users', 'pricing') + SOURCE_DBNAME: str = "sage" # db alias for Sage source DB + CACHE_DBNAME: str = "sageapi" # db alias for local cache DB + BATCH_SIZE: int = 500 # batch size for persist + MAX_RETRIES: int = 3 # max retry attempts per batch + RETRY_DELAY: float = 1.0 # initial retry delay in seconds - @abstractmethod - async def fetch_incremental(self, since_timestamp: str | None = None) -> list[dict[str, Any]]: - """Fetch incremental data from the upstream source. + # sync_state table key + STATE_KEY: str = "" - Args: - since_timestamp: Only fetch records modified after this timestamp. - None means full sync. + def __init__(self, env: Optional[ServerEnv] = None): + self.env = env or ServerEnv() - Returns: - List of records to be persisted. - """ - ... + # ------------------------------------------------------------------ # + # Checkpoint helpers – sync_state table lives in the CACHE DB # + # ------------------------------------------------------------------ # - @abstractmethod - async def persist(self, records: list[dict[str, Any]]) -> int: - """Persist fetched records to the local database. + async def _read_checkpoint(self) -> Optional[str]: + """Read last sync timestamp from sync_state table.""" + async with get_sor_context(self.env, self.CACHE_DBNAME) as sor: + recs = await sor.R('sync_state', {'state_key': self.STATE_KEY}) + if recs and len(recs) > 0: + return recs[0].get('last_sync_ts') + return None - Args: - records: List of records to upsert. + async def _write_checkpoint(self, timestamp: str) -> None: + """Write new sync timestamp into sync_state table.""" + async with get_sor_context(self.env, self.CACHE_DBNAME) as sor: + now_ts = str(int(time.time())) + existing = await sor.R('sync_state', {'state_key': self.STATE_KEY}) + if existing and len(existing) > 0: + await sor.U('sync_state', { + 'state_key': self.STATE_KEY, + 'last_sync_ts': timestamp, + 'updated_at': now_ts, + }) + else: + await sor.C('sync_state', { + 'state_key': self.STATE_KEY, + 'last_sync_ts': timestamp, + 'created_at': now_ts, + 'updated_at': now_ts, + }) - Returns: - Number of records successfully persisted. - """ - ... + # ------------------------------------------------------------------ # + # Retry wrapper # + # ------------------------------------------------------------------ # - @abstractmethod - def get_latest_timestamp(self, records: list[dict[str, Any]]) -> str | None: - """Extract the latest modification timestamp from a batch of records. + async def _with_retry(self, coro_func, *args, **kwargs) -> Any: + """Execute an async function with exponential-backoff retry.""" + last_exc = None + delay = self.RETRY_DELAY + for attempt in range(1, self.MAX_RETRIES + 1): + try: + return await coro_func(*args, **kwargs) + except Exception as e: + last_exc = e + logger.warning( + "[%s] attempt %d/%d failed: %s", + self.__class__.__name__, attempt, self.MAX_RETRIES, e, + ) + if attempt < self.MAX_RETRIES: + await self._sleep(delay) + delay *= 2 + raise last_exc - Used to advance the sync checkpoint after successful persist. - """ - ... + @staticmethod + async def _sleep(seconds: float) -> None: + import asyncio + await asyncio.sleep(seconds) - async def _load_checkpoint(self) -> str | None: - """Load the last successful sync checkpoint timestamp. + # ------------------------------------------------------------------ # + # Batch persist # + # ------------------------------------------------------------------ # - TODO: Implement checkpoint persistence (sync_state table). - """ - checkpoint = self._last_checkpoint.get(self.sync_name) - debug(f'Sync {self.sync_name}: loaded checkpoint = {checkpoint}') - return checkpoint + async def _persist_batch(self, sor, records: List[Dict]) -> int: + """Persist a single batch of records with retry.""" + async def _do(): + await self.persist(sor, records) + await self._with_retry(_do) + return len(records) - async def _save_checkpoint(self, timestamp: str) -> None: - """Save the sync checkpoint after a successful run. - - TODO: Implement checkpoint persistence (sync_state table). - """ - self._last_checkpoint[self.sync_name] = timestamp - debug(f'Sync {self.sync_name}: saved checkpoint = {timestamp}') - - async def run(self) -> dict[str, Any]: - """Execute a full sync cycle. - - Returns: - dict with keys: success, records_fetched, records_persisted, - error (if any), duration_seconds - """ - start = time.time() - result: dict[str, Any] = { - 'sync_name': self.sync_name, - 'success': False, - 'records_fetched': 0, - 'records_persisted': 0, - 'error': None, - 'duration_seconds': 0.0, - } - - try: - checkpoint = await self._load_checkpoint() - info(f'Sync {self.sync_name}: starting (checkpoint={checkpoint})') - - records = await self.fetch_incremental(since_timestamp=checkpoint) - result['records_fetched'] = len(records) - - if records: - persisted = await self.persist(records) - result['records_persisted'] = persisted - - latest_ts = self.get_latest_timestamp(records) - if latest_ts: - await self._save_checkpoint(latest_ts) - - result['success'] = True - info( - f'Sync {self.sync_name}: completed — ' - f'fetched={result["records_fetched"]}, ' - f'persisted={result["records_persisted"]}' + async def persist_in_batches(self, sor, records: List[Dict]) -> int: + """Split records into batches and persist each with retry.""" + total = 0 + for i in range(0, len(records), self.BATCH_SIZE): + batch = records[i:i + self.BATCH_SIZE] + cnt = await self._persist_batch(sor, batch) + total += cnt + logger.info( + "[%s] persisted batch %d/%d (%d records)", + self.__class__.__name__, + i // self.BATCH_SIZE + 1, + (len(records) + self.BATCH_SIZE - 1) // self.BATCH_SIZE, + cnt, ) + return total - except Exception as e: - error(f'Sync {self.sync_name}: failed with error: {e}') - result['error'] = str(e) + # ------------------------------------------------------------------ # + # Main sync flow # + # ------------------------------------------------------------------ # - finally: - result['duration_seconds'] = round(time.time() - start, 3) + async def sync(self) -> Dict[str, Any]: + """ + Full incremental sync flow: + 1. Read checkpoint (last sync timestamp) + 2. Fetch incremental records from Sage DB + 3. Persist to local cache in batches + 4. Update checkpoint + 5. Return summary dict + """ + cls_name = self.__class__.__name__ + logger.info("[%s] sync started", cls_name) + # 1. Read checkpoint + since_ts = await self._read_checkpoint() + logger.info("[%s] checkpoint: %s", cls_name, since_ts or "None (full sync)") + + # 2. Fetch incremental from Sage source DB + async with get_sor_context(self.env, self.SOURCE_DBNAME) as sor: + records = await self.fetch_incremental(sor, since_ts) + + if not records: + logger.info("[%s] no new records, sync done", cls_name) + return { + 'module': self.MODULE_NAME, + 'fetched': 0, + 'persisted': 0, + 'new_checkpoint': since_ts, + } + + # 3. Extract latest timestamp + new_checkpoint = self.get_latest_timestamp(records) + logger.info( + "[%s] fetched %d records, latest_ts=%s", + cls_name, len(records), new_checkpoint, + ) + + # 4. Persist to cache DB + async with get_sor_context(self.env, self.CACHE_DBNAME) as cache_sor: + persisted = await self.persist_in_batches(cache_sor, records) + + # 5. Update checkpoint + if new_checkpoint: + await self._write_checkpoint(new_checkpoint) + + result = { + 'module': self.MODULE_NAME, + 'fetched': len(records), + 'persisted': persisted, + 'new_checkpoint': new_checkpoint, + } + logger.info("[%s] sync completed: %s", cls_name, result) return result + + # ------------------------------------------------------------------ # + # Abstract methods – subclasses MUST implement # + # ------------------------------------------------------------------ # + + @abstractmethod + async def fetch_incremental(self, sor, since_timestamp: Optional[str]) -> List[Dict]: + """ + Fetch incremental records from Sage DB since the given timestamp. + + Args: + sor: sqlor context for the SOURCE DB + since_timestamp: ISO or unix timestamp string, or None for full sync + + Returns: + List of record dicts + """ + ... + + @abstractmethod + async def persist(self, sor, records: List[Dict]) -> None: + """ + UPSERT records into the local cache table. + + Args: + sor: sqlor context for the CACHE DB + records: list of dicts to upsert + """ + ... + + @abstractmethod + def get_latest_timestamp(self, records: List[Dict]) -> Optional[str]: + """ + Extract the maximum updated/modified timestamp from records. + + Args: + records: list of record dicts + + Returns: + Timestamp string or None if no records + """ + ... + + +async def run_all_syncs() -> str: + """Trigger sync for all entity types. Called by admin API or cron.""" + import json + from sageapi.sync.user_sync import UserSync + from sageapi.sync.pricing_sync import PricingSync + from sageapi.sync.uapi_sync import UapiSync + from sageapi.sync.llmage_sync import LlmageSync + + results = [] + for sync_cls in [UserSync, PricingSync, UapiSync, LlmageSync]: + try: + syncer = sync_cls() + result = await syncer.sync() + results.append({'module': sync_cls.MODULE_NAME, 'status': 'ok', **result}) + except Exception as e: + logger.exception("[%s] sync failed", sync_cls.__name__) + results.append({'module': sync_cls.MODULE_NAME, 'status': 'error', 'error': str(e)}) + + return json.dumps({'success': True, 'results': results}, ensure_ascii=False, default=str) diff --git a/sageapi/sync/llmage_sync.py b/sageapi/sync/llmage_sync.py index 4deb9be..f266dfe 100644 --- a/sageapi/sync/llmage_sync.py +++ b/sageapi/sync/llmage_sync.py @@ -1,65 +1,142 @@ -"""LLM image data synchronization for SageAPI. - -Syncs LLM catalog and provider data from the upstream Sage -llmage module into the local llmage_cache table. """ +LlmageSync - Sync llm / llmcatelog / llm_api_map from Sage DB to llmage_cache. -from __future__ import annotations +Source tables (Sage DB): + - llm + - llmcatelog + - llm_api_map -from typing import Any +Target table (cache DB): + - llmage_cache +""" +import logging +from typing import Dict, List, Optional -from appPublic.log import debug, info from .base_sync import BaseSync +logger = logging.getLogger(__name__) + class LlmageSync(BaseSync): - """Incremental sync for llmage data from Sage upstream.""" + MODULE_NAME = "llmage" + SOURCE_DBNAME = "sage" + CACHE_DBNAME = "sageapi" + STATE_KEY = "sync_llmage" + BATCH_SIZE = 500 - def __init__(self, batch_size: int = 500) -> None: - super().__init__(sync_name='llmage', batch_size=batch_size) - - async def fetch_incremental(self, since_timestamp: str | None = None) -> list[dict[str, Any]]: - """Fetch llmage data updated since the last sync checkpoint. - - TODO: Implement upstream API call to Sage /api/llmage endpoint. + async def fetch_incremental(self, sor, since_timestamp: Optional[str]) -> List[Dict]: """ - debug(f'LlmageSync: fetching incremental data since {since_timestamp}') - # Placeholder: call upstream Sage API - return [] - - async def persist(self, records: list[dict[str, Any]]) -> int: - """Upsert llmage records into llmage_cache table. - - TODO: Implement database upsert logic. + Fetch incremental data from llm, llmcatelog, and llm_api_map tables. + Joins LLM model info with catalog and API mapping data. """ - if not records: - return 0 - info(f'LlmageSync: persisting {len(records)} llmage records') - # Placeholder: upsert into llmage_cache - return len(records) + if since_timestamp: + where_clause = f"WHERE l.updated_at > '{since_timestamp}' OR lc.updated_at > '{since_timestamp}' OR lam.updated_at > '{since_timestamp}'" + else: + where_clause = "" - def get_latest_timestamp(self, records: list[dict[str, Any]]) -> str | None: - """Extract the maximum updated_at from the record batch.""" + sql = f""" + SELECT + l.id AS llm_id, + l.model_name, + l.model_version, + l.provider, + l.model_type, + l.status AS llm_status, + l.description AS llm_description, + l.created_at AS llm_created_at, + l.updated_at AS llm_updated_at, + lc.id AS catelog_id, + lc.catelog_name, + lc.catelog_code, + lc.sort_order AS catelog_sort, + lc.status AS catelog_status, + lc.updated_at AS catelog_updated_at, + lam.id AS api_map_id, + lam.api_name, + lam.api_endpoint, + lam.api_version, + lam.auth_type, + lam.rate_limit, + lam.status AS api_map_status, + lam.updated_at AS api_map_updated_at + FROM {sor.dbname}.llm l + LEFT JOIN {sor.dbname}.llmcatelog lc ON l.catelog_id = lc.id + LEFT JOIN {sor.dbname}.llm_api_map lam ON l.id = lam.llm_id + {where_clause} + ORDER BY COALESCE(l.updated_at, l.created_at) ASC + """ + + records = await sor.sqlExe(sql, {}) + return [dict(r) for r in records] if records else [] + + async def persist(self, sor, records: List[Dict]) -> None: + """ + UPSERT into llmage_cache using INSERT ... ON DUPLICATE KEY UPDATE. + The composite key is (llm_id, catelog_id, api_map_id). + """ + import time + synced_at = str(int(time.time())) + + for rec in records: + rec['synced_at'] = synced_at + insert_sql = """ + INSERT INTO llmage_cache ( + llm_id, model_name, model_version, provider, model_type, + llm_status, llm_description, llm_created_at, llm_updated_at, + catelog_id, catelog_name, catelog_code, catelog_sort, + catelog_status, catelog_updated_at, + api_map_id, api_name, api_endpoint, api_version, + auth_type, rate_limit, api_map_status, api_map_updated_at, + synced_at + ) VALUES ( + ${llm_id}$, ${model_name}$, ${model_version}$, ${provider}$, ${model_type}$, + ${llm_status}$, ${llm_description}$, ${llm_created_at}$, ${llm_updated_at}$, + ${catelog_id}$, ${catelog_name}$, ${catelog_code}$, ${catelog_sort}$, + ${catelog_status}$, ${catelog_updated_at}$, + ${api_map_id}$, ${api_name}$, ${api_endpoint}$, ${api_version}$, + ${auth_type}$, ${rate_limit}$, ${api_map_status}$, ${api_map_updated_at}$, + ${synced_at}$ + ) + ON DUPLICATE KEY UPDATE + model_name = VALUES(model_name), + model_version = VALUES(model_version), + provider = VALUES(provider), + model_type = VALUES(model_type), + llm_status = VALUES(llm_status), + llm_description = VALUES(llm_description), + llm_created_at = VALUES(llm_created_at), + llm_updated_at = VALUES(llm_updated_at), + catelog_name = VALUES(catelog_name), + catelog_code = VALUES(catelog_code), + catelog_sort = VALUES(catelog_sort), + catelog_status = VALUES(catelog_status), + catelog_updated_at= VALUES(catelog_updated_at), + api_name = VALUES(api_name), + api_endpoint = VALUES(api_endpoint), + api_version = VALUES(api_version), + auth_type = VALUES(auth_type), + rate_limit = VALUES(rate_limit), + api_map_status = VALUES(api_map_status), + api_map_updated_at= VALUES(api_map_updated_at), + synced_at = VALUES(synced_at) + """ + try: + await sor.execute(insert_sql, rec) + except Exception as e: + logger.warning( + "[%s] persist failed for llm_id=%s: %s", + self.__class__.__name__, rec.get('llm_id'), e, + ) + raise + + def get_latest_timestamp(self, records: List[Dict]) -> Optional[str]: + """Extract the maximum updated_at from llm, catelog, or api_map records.""" if not records: return None - timestamps = [r.get('updated_at') for r in records if r.get('updated_at')] - return max(timestamps) if timestamps else None - - -_llmage_sync_instance: LlmageSync | None = None - - -def get_llmage_sync() -> LlmageSync: - """Get or create the LlmageSync singleton.""" - global _llmage_sync_instance - if _llmage_sync_instance is None: - _llmage_sync_instance = LlmageSync() - return _llmage_sync_instance - - -async def sync_llmage(since_timestamp: str | None = None) -> dict[str, Any]: - """Run a llmage data sync cycle.""" - syncer = get_llmage_sync() - if since_timestamp: - await syncer._save_checkpoint(since_timestamp) - return await syncer.run() + latest = None + for r in records: + for key in ('llm_updated_at', 'catelog_updated_at', 'api_map_updated_at'): + ts = r.get(key) + if ts and (latest is None or str(ts) > str(latest)): + latest = str(ts) + return latest diff --git a/sageapi/sync/pricing_sync.py b/sageapi/sync/pricing_sync.py index 61a9d13..1f7cfb3 100644 --- a/sageapi/sync/pricing_sync.py +++ b/sageapi/sync/pricing_sync.py @@ -1,65 +1,123 @@ -"""Pricing data synchronization for SageAPI. - -Syncs pricing program and timing data from the upstream Sage -pricing module into the local pricing_cache table. """ +PricingSync - Sync pricing_program / pricing_program_timing from Sage DB to pricing_cache. -from __future__ import annotations +Source tables (Sage DB): + - pricing_program + - pricing_program_timing -from typing import Any +Target table (cache DB): + - pricing_cache +""" +import logging +from typing import Dict, List, Optional -from appPublic.log import debug, info from .base_sync import BaseSync +logger = logging.getLogger(__name__) + class PricingSync(BaseSync): - """Incremental sync for pricing data from Sage upstream.""" + MODULE_NAME = "pricing" + SOURCE_DBNAME = "sage" + CACHE_DBNAME = "sageapi" + STATE_KEY = "sync_pricing" + BATCH_SIZE = 500 - def __init__(self, batch_size: int = 500) -> None: - super().__init__(sync_name='pricing', batch_size=batch_size) - - async def fetch_incremental(self, since_timestamp: str | None = None) -> list[dict[str, Any]]: - """Fetch pricing data updated since the last sync checkpoint. - - TODO: Implement upstream API call to Sage /api/pricing endpoint. + async def fetch_incremental(self, sor, since_timestamp: Optional[str]) -> List[Dict]: """ - debug(f'PricingSync: fetching incremental data since {since_timestamp}') - # Placeholder: call upstream Sage API - return [] - - async def persist(self, records: list[dict[str, Any]]) -> int: - """Upsert pricing records into pricing_cache table. - - TODO: Implement database upsert logic. + Fetch incremental data from pricing_program and pricing_program_timing. + Joins program info with timing/schedule data. """ - if not records: - return 0 - info(f'PricingSync: persisting {len(records)} pricing records') - # Placeholder: upsert into pricing_cache - return len(records) + if since_timestamp: + where_clause = f"WHERE pp.updated_at > '{since_timestamp}' OR ppt.updated_at > '{since_timestamp}'" + else: + where_clause = "" - def get_latest_timestamp(self, records: list[dict[str, Any]]) -> str | None: - """Extract the maximum updated_at from the record batch.""" + sql = f""" + SELECT + pp.id AS program_id, + pp.program_name, + pp.program_code, + pp.program_type, + pp.status AS program_status, + pp.description, + pp.created_at AS program_created_at, + pp.updated_at AS program_updated_at, + ppt.id AS timing_id, + ppt.start_time, + ppt.end_time, + ppt.duration, + ppt.repeat_rule, + ppt.timezone, + ppt.status AS timing_status, + ppt.updated_at AS timing_updated_at + FROM {sor.dbname}.pricing_program pp + LEFT JOIN {sor.dbname}.pricing_program_timing ppt + ON pp.id = ppt.program_id + {where_clause} + ORDER BY COALESCE(pp.updated_at, pp.created_at) ASC + """ + + records = await sor.sqlExe(sql, {}) + return [dict(r) for r in records] if records else [] + + async def persist(self, sor, records: List[Dict]) -> None: + """ + UPSERT into pricing_cache using INSERT ... ON DUPLICATE KEY UPDATE. + The composite key is (program_id, timing_id). + """ + import time + synced_at = str(int(time.time())) + + for rec in records: + rec['synced_at'] = synced_at + insert_sql = """ + INSERT INTO pricing_cache ( + program_id, program_name, program_code, program_type, + program_status, description, program_created_at, program_updated_at, + timing_id, start_time, end_time, duration, + repeat_rule, timezone, timing_status, timing_updated_at, + synced_at + ) VALUES ( + ${program_id}$, ${program_name}$, ${program_code}$, ${program_type}$, + ${program_status}$, ${description}$, ${program_created_at}$, ${program_updated_at}$, + ${timing_id}$, ${start_time}$, ${end_time}$, ${duration}$, + ${repeat_rule}$, ${timezone}$, ${timing_status}$, ${timing_updated_at}$, + ${synced_at}$ + ) + ON DUPLICATE KEY UPDATE + program_name = VALUES(program_name), + program_code = VALUES(program_code), + program_type = VALUES(program_type), + program_status = VALUES(program_status), + description = VALUES(description), + program_created_at = VALUES(program_created_at), + program_updated_at = VALUES(program_updated_at), + start_time = VALUES(start_time), + end_time = VALUES(end_time), + duration = VALUES(duration), + repeat_rule = VALUES(repeat_rule), + timezone = VALUES(timezone), + timing_status = VALUES(timing_status), + timing_updated_at = VALUES(timing_updated_at), + synced_at = VALUES(synced_at) + """ + try: + await sor.execute(insert_sql, rec) + except Exception as e: + logger.warning( + "[%s] persist failed for program_id=%s: %s", + self.__class__.__name__, rec.get('program_id'), e, + ) + raise + + def get_latest_timestamp(self, records: List[Dict]) -> Optional[str]: + """Extract the maximum updated_at from program or timing records.""" if not records: return None - timestamps = [r.get('updated_at') for r in records if r.get('updated_at')] - return max(timestamps) if timestamps else None - - -_pricing_sync_instance: PricingSync | None = None - - -def get_pricing_sync() -> PricingSync: - """Get or create the PricingSync singleton.""" - global _pricing_sync_instance - if _pricing_sync_instance is None: - _pricing_sync_instance = PricingSync() - return _pricing_sync_instance - - -async def sync_pricing(since_timestamp: str | None = None) -> dict[str, Any]: - """Run a pricing data sync cycle.""" - syncer = get_pricing_sync() - if since_timestamp: - await syncer._save_checkpoint(since_timestamp) - return await syncer.run() + latest = None + for r in records: + ts = r.get('program_updated_at') or r.get('timing_updated_at') + if ts and (latest is None or str(ts) > str(latest)): + latest = str(ts) + return latest diff --git a/sageapi/sync/uapi_sync.py b/sageapi/sync/uapi_sync.py index 13c829f..4725689 100644 --- a/sageapi/sync/uapi_sync.py +++ b/sageapi/sync/uapi_sync.py @@ -1,65 +1,129 @@ -"""UAPI data synchronization for SageAPI. - -Syncs uapi application and caller configuration from the upstream -Sage uapi module into the local uapi_cache table. """ +UAPISync - Sync uapi / upapp from Sage DB to uapi_cache. -from __future__ import annotations +Source tables (Sage DB): + - uapi + - upapp -from typing import Any +Target table (cache DB): + - uapi_cache +""" +import logging +from typing import Dict, List, Optional -from appPublic.log import debug, info from .base_sync import BaseSync +logger = logging.getLogger(__name__) + class UAPISync(BaseSync): - """Incremental sync for uapi data from Sage upstream.""" + MODULE_NAME = "uapi" + SOURCE_DBNAME = "sage" + CACHE_DBNAME = "sageapi" + STATE_KEY = "sync_uapi" + BATCH_SIZE = 500 - def __init__(self, batch_size: int = 500) -> None: - super().__init__(sync_name='uapi', batch_size=batch_size) - - async def fetch_incremental(self, since_timestamp: str | None = None) -> list[dict[str, Any]]: - """Fetch uapi data updated since the last sync checkpoint. - - TODO: Implement upstream API call to Sage /api/uapi endpoint. + async def fetch_incremental(self, sor, since_timestamp: Optional[str]) -> List[Dict]: """ - debug(f'UAPISync: fetching incremental data since {since_timestamp}') - # Placeholder: call upstream Sage API - return [] - - async def persist(self, records: list[dict[str, Any]]) -> int: - """Upsert uapi records into uapi_cache table. - - TODO: Implement database upsert logic. + Fetch incremental data from uapi and upapp tables. + Joins API definitions with app registration data. """ - if not records: - return 0 - info(f'UAPISync: persisting {len(records)} uapi records') - # Placeholder: upsert into uapi_cache - return len(records) + if since_timestamp: + where_clause = f"WHERE u.updated_at > '{since_timestamp}' OR up.updated_at > '{since_timestamp}'" + else: + where_clause = "" - def get_latest_timestamp(self, records: list[dict[str, Any]]) -> str | None: - """Extract the maximum updated_at from the record batch.""" + sql = f""" + SELECT + u.id AS uapi_id, + u.api_name, + u.api_path, + u.api_method, + u.api_version, + u.api_desc, + u.status AS uapi_status, + u.auth_required, + u.created_at AS uapi_created_at, + u.updated_at AS uapi_updated_at, + up.id AS upapp_id, + up.app_name, + up.app_code, + up.app_type, + up.app_desc, + up.app_owner, + up.status AS upapp_status, + up.updated_at AS upapp_updated_at + FROM {sor.dbname}.uapi u + LEFT JOIN {sor.dbname}.upapp up ON u.upapp_id = up.id + {where_clause} + ORDER BY COALESCE(u.updated_at, u.created_at) ASC + """ + + records = await sor.sqlExe(sql, {}) + return [dict(r) for r in records] if records else [] + + async def persist(self, sor, records: List[Dict]) -> None: + """ + UPSERT into uapi_cache using INSERT ... ON DUPLICATE KEY UPDATE. + The composite key is (uapi_id, upapp_id). + """ + import time + synced_at = str(int(time.time())) + + for rec in records: + rec['synced_at'] = synced_at + insert_sql = """ + INSERT INTO uapi_cache ( + uapi_id, api_name, api_path, api_method, api_version, + api_desc, uapi_status, auth_required, + uapi_created_at, uapi_updated_at, + upapp_id, app_name, app_code, app_type, + app_desc, app_owner, upapp_status, upapp_updated_at, + synced_at + ) VALUES ( + ${uapi_id}$, ${api_name}$, ${api_path}$, ${api_method}$, ${api_version}$, + ${api_desc}$, ${uapi_status}$, ${auth_required}$, + ${uapi_created_at}$, ${uapi_updated_at}$, + ${upapp_id}$, ${app_name}$, ${app_code}$, ${app_type}$, + ${app_desc}$, ${app_owner}$, ${upapp_status}$, ${upapp_updated_at}$, + ${synced_at}$ + ) + ON DUPLICATE KEY UPDATE + api_name = VALUES(api_name), + api_path = VALUES(api_path), + api_method = VALUES(api_method), + api_version = VALUES(api_version), + api_desc = VALUES(api_desc), + uapi_status = VALUES(uapi_status), + auth_required = VALUES(auth_required), + uapi_created_at = VALUES(uapi_created_at), + uapi_updated_at = VALUES(uapi_updated_at), + app_name = VALUES(app_name), + app_code = VALUES(app_code), + app_type = VALUES(app_type), + app_desc = VALUES(app_desc), + app_owner = VALUES(app_owner), + upapp_status = VALUES(upapp_status), + upapp_updated_at = VALUES(upapp_updated_at), + synced_at = VALUES(synced_at) + """ + try: + await sor.execute(insert_sql, rec) + except Exception as e: + logger.warning( + "[%s] persist failed for uapi_id=%s: %s", + self.__class__.__name__, rec.get('uapi_id'), e, + ) + raise + + def get_latest_timestamp(self, records: List[Dict]) -> Optional[str]: + """Extract the maximum updated_at from uapi or upapp records.""" if not records: return None - timestamps = [r.get('updated_at') for r in records if r.get('updated_at')] - return max(timestamps) if timestamps else None - - -_uapi_sync_instance: UAPISync | None = None - - -def get_uapi_sync() -> UAPISync: - """Get or create the UAPISync singleton.""" - global _uapi_sync_instance - if _uapi_sync_instance is None: - _uapi_sync_instance = UAPISync() - return _uapi_sync_instance - - -async def sync_uapi(since_timestamp: str | None = None) -> dict[str, Any]: - """Run a uapi data sync cycle.""" - syncer = get_uapi_sync() - if since_timestamp: - await syncer._save_checkpoint(since_timestamp) - return await syncer.run() + latest = None + for r in records: + for key in ('uapi_updated_at', 'upapp_updated_at'): + ts = r.get(key) + if ts and (latest is None or str(ts) > str(latest)): + latest = str(ts) + return latest diff --git a/sageapi/sync/user_sync.py b/sageapi/sync/user_sync.py index f53e9f4..bbae917 100644 --- a/sageapi/sync/user_sync.py +++ b/sageapi/sync/user_sync.py @@ -1,76 +1,143 @@ -"""User data synchronization for SageAPI. - -Syncs user data from the upstream Sage system into the local -users_cache table. Uses incremental sync based on updated_at -timestamp to minimize data transfer. """ +UserSync - Sync users / organi / organization from Sage DB to users_cache. -from __future__ import annotations +Source tables (Sage DB): + - users + - organi + - organization -from typing import Any +Target table (cache DB): + - users_cache +""" +import logging +from typing import Dict, List, Optional -from appPublic.log import debug, info from .base_sync import BaseSync +logger = logging.getLogger(__name__) + class UserSync(BaseSync): - """Incremental sync for user data from Sage upstream.""" + MODULE_NAME = "users" + SOURCE_DBNAME = "sage" + CACHE_DBNAME = "sageapi" + STATE_KEY = "sync_users" + BATCH_SIZE = 500 - def __init__(self, batch_size: int = 500) -> None: - super().__init__(sync_name='users', batch_size=batch_size) - - async def fetch_incremental(self, since_timestamp: str | None = None) -> list[dict[str, Any]]: - """Fetch users updated since the last sync checkpoint. - - TODO: Implement upstream API call to Sage /api/users endpoint. + async def fetch_incremental(self, sor, since_timestamp: Optional[str]) -> List[Dict]: """ - debug(f'UserSync: fetching incremental data since {since_timestamp}') - # Placeholder: call upstream Sage API - # GET /api/users?updated_at_gt={since_timestamp}&limit={batch_size} - return [] - - async def persist(self, records: list[dict[str, Any]]) -> int: - """Upsert user records into users_cache table. - - TODO: Implement database upsert logic. + Fetch incremental data from users, organi, organization tables. + Uses LEFT JOIN to combine user data with organization info. """ - if not records: - return 0 - info(f'UserSync: persisting {len(records)} user records') - # Placeholder: upsert into users_cache - return len(records) + if since_timestamp: + where_clause = f"WHERE u.updated_at > '{since_timestamp}' OR o.updated_at > '{since_timestamp}'" + else: + where_clause = "" - def get_latest_timestamp(self, records: list[dict[str, Any]]) -> str | None: - """Extract the maximum updated_at from the record batch.""" + sql = f""" + SELECT + u.id AS user_id, + u.username, + u.email, + u.status AS user_status, + u.created_at AS user_created_at, + u.updated_at AS user_updated_at, + oi.organi_id AS organi_id, + oi.organi_name AS organi_name, + oi.parent_id AS organi_parent_id, + org.id AS org_id, + org.org_name AS org_name, + org.org_type AS org_type, + org.status AS org_status, + org.updated_at AS org_updated_at + FROM {sor.dbname}.users u + LEFT JOIN {sor.dbname}.organi oi ON u.organi_id = oi.id + LEFT JOIN {sor.dbname}.organization org ON oi.organization_id = org.id + {where_clause} + ORDER BY COALESCE(u.updated_at, u.created_at) ASC + """ + + records = await sor.sqlExe(sql, {}) + return [dict(r) for r in records] if records else [] + + async def persist(self, sor, records: List[Dict]) -> None: + """ + UPSERT into users_cache. + For each record: try UPDATE first; if no rows affected, INSERT. + """ + for rec in records: + # Try update first + update_sql = """ + UPDATE users_cache SET + username = ${username}$, + email = ${email}$, + user_status = ${user_status}$, + user_created_at = ${user_created_at}$, + user_updated_at = ${user_updated_at}$, + organi_id = ${organi_id}$, + organi_name = ${organi_name}$, + organi_parent_id= ${organi_parent_id}$, + org_id = ${org_id}$, + org_name = ${org_name}$, + org_type = ${org_type}$, + org_status = ${org_status}$, + org_updated_at = ${org_updated_at}$, + synced_at = ${synced_at}$ + WHERE user_id = ${user_id}$ + """ + rec['synced_at'] = str(int(__import__('time').time())) + await sor.execute(update_sql, rec) + + # If no rows updated (sqlor returns affected count), insert + # sqlor's execute returns the cursor; we check rowcount via sqlExe + # A simpler approach: use INSERT ... ON DUPLICATE KEY UPDATE + insert_sql = """ + INSERT INTO users_cache ( + user_id, username, email, user_status, + user_created_at, user_updated_at, + organi_id, organi_name, organi_parent_id, + org_id, org_name, org_type, org_status, + org_updated_at, synced_at + ) VALUES ( + ${user_id}$, ${username}$, ${email}$, ${user_status}$, + ${user_created_at}$, ${user_updated_at}$, + ${organi_id}$, ${organi_name}$, ${organi_parent_id}$, + ${org_id}$, ${org_name}$, ${org_type}$, ${org_status}$, + ${org_updated_at}$, ${synced_at}$ + ) + ON DUPLICATE KEY UPDATE + username = VALUES(username), + email = VALUES(email), + user_status = VALUES(user_status), + user_created_at = VALUES(user_created_at), + user_updated_at = VALUES(user_updated_at), + organi_id = VALUES(organi_id), + organi_name = VALUES(organi_name), + organi_parent_id= VALUES(organi_parent_id), + org_id = VALUES(org_id), + org_name = VALUES(org_name), + org_type = VALUES(org_type), + org_status = VALUES(org_status), + org_updated_at = VALUES(org_updated_at), + synced_at = VALUES(synced_at) + """ + # Execute INSERT with ON DUPLICATE KEY UPDATE as safety net + # The UPDATE above handles most cases; this catches any race conditions + # Skip if the user_id is None + if rec.get('user_id') is not None: + try: + await sor.execute(insert_sql, rec) + except Exception: + # Duplicate key is expected if UPDATE already succeeded + pass + + def get_latest_timestamp(self, records: List[Dict]) -> Optional[str]: + """Extract the maximum updated_at from all records.""" if not records: return None - timestamps = [r.get('updated_at') for r in records if r.get('updated_at')] - return max(timestamps) if timestamps else None - - -# Module-level singleton instance -_user_sync_instance: UserSync | None = None - - -def get_user_sync() -> UserSync: - """Get or create the UserSync singleton.""" - global _user_sync_instance - if _user_sync_instance is None: - _user_sync_instance = UserSync() - return _user_sync_instance - - -async def sync_users(since_timestamp: str | None = None) -> dict[str, Any]: - """Run a user data sync cycle. - - Args: - since_timestamp: Optional override for the checkpoint timestamp. - - Returns: - Sync result dict from BaseSync.run(). - """ - syncer = get_user_sync() - if since_timestamp: - # Override checkpoint for this run - await syncer._save_checkpoint(since_timestamp) - return await syncer.run() + latest = None + for r in records: + ts = r.get('user_updated_at') or r.get('org_updated_at') + if ts and (latest is None or str(ts) > str(latest)): + latest = str(ts) + return latest diff --git a/sageapi/utils/__pycache__/__init__.cpython-310.pyc b/sageapi/utils/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97fc645195fa5c72f8aaca559ba09e2936e3a43d GIT binary patch literal 118 zcmd1j<>g`k0@WtoED-$|L?8o3AjbiSi&=m~3PUi1CZpdo5vX!#!2!-Mk+esl!Yn` z7GVOq&wKJNmEgb?R8+bTMpY_RDmbH2LRFj7IBkTncG?RiUk*+&LRFGahDnmDP;cuo zw4L>&m%BK5?nota2WHPbn7J#GhK)|eO)aEH#I zG_+1^2~z(+fXmL0AW%i1(2ry*=xL`jQgVJ}WYM!OPQ+mF^amUg$-?|rt4 z$Ax3HL)nJVQFxxVBT2JS45LKxmcVdBm*P6$%XA9@-bj-sggH@Bh;ZE1&Rxl2Q80xN zQ`zfuIIQ0oLxRdin|C-}T;JSX`ex%%wODmswB(we0{4IcTZ5f&n#Cb*;YjH|Kw~u5 zirF$+;{(Sq4hA@E7c+magFQC+13+x`{t~bwWpH#129V;2vZ%$STGpF*UFxdl(&yEQ z+--NlM(!m1F!zj0UdE;4XfO9-{rW6#T z?zeLBu$=pS-eI zx$g}8q4mo81@935`1ftdSxr5&jY0``9(?z3>t3OSZ)M$R-xnz`>VLyEJl7h1qA`KX z0h@F&?5cFB#S<<<$VQ|9?_{bslD+?k(>6akBQ?rQA>aiqAd{@erY_7=r0zr7cZvq+&3pb zBft|FmZ&mzFk9sQZRZjZpF&sFGglqrZq!ECFvkVfs=g7T3*#tBdR%DB>u5>D2l(>L zcC~|M>4gc$$Mu=sjO%Hp@^Zb-(nh_W2laZh3*TS7UKjWUW)em#C&noER4;>%B;nu~ v-l|%{G`aQR#sZ^5q?OVCqLZ?2%)df>BzC#hl;_(hhEpVPf@@PZrmy@9vLEm2 literal 0 HcmV?d00001 diff --git a/sageapi/utils/__pycache__/http_client.cpython-310.pyc b/sageapi/utils/__pycache__/http_client.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..704d5a72806afc2948c8b7638bb8ff988525b385 GIT binary patch literal 12210 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 OpDB@P-zyxQn)^S{xF}oz literal 0 HcmV?d00001 diff --git a/sageapi/utils/http_client.py b/sageapi/utils/http_client.py index 6773525..bac8ef9 100644 --- a/sageapi/utils/http_client.py +++ b/sageapi/utils/http_client.py @@ -1,115 +1,460 @@ -"""HTTP client for upstream Sage API calls. +""" +SageHttpClient - Async HTTP client using aiohttp with DAPI signature support. -Provides a reusable async HTTP client with connection pooling, -retry logic, and automatic DAPI/UAPI authentication headers. +Used to call external APIs (e.g., LLM provider APIs). Not for calling Sage local interfaces. + +Features: + - Automatic DAPI signature header injection + - Connection pooling + - Retry with exponential backoff + - Configurable timeouts + - Support for GET, POST, PUT, DELETE, PATCH + +Usage: + from sageapi.utils.http_client import SageHttpClient + + client = SageHttpClient( + api_key="your-api-key", + api_secret="your-api-secret", + base_url="https://api.example.com", + max_retries=3, + timeout=30.0, + ) + + async with client: + resp = await client.post("/v1/chat", json={"message": "hello"}) + data = await resp.json() """ -from __future__ import annotations +import hashlib +import hmac +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Optional -import asyncio -from typing import Any +import aiohttp +from aiohttp import ClientTimeout -from appPublic.log import debug, error +logger = logging.getLogger(__name__) + +# Default configuration +DEFAULT_TIMEOUT = 30.0 +DEFAULT_MAX_RETRIES = 3 +DEFAULT_BACKOFF_FACTOR = 0.5 +DEFAULT_MAX_CONNECTIONS = 100 +DEFAULT_CONNECTION_POOL_LIMIT = 100 + + +@dataclass +class RetryConfig: + """Configuration for request retries.""" + + max_retries: int = DEFAULT_MAX_RETRIES + backoff_factor: float = DEFAULT_BACKOFF_FACTOR + retry_on_status: set[int] = field(default_factory=lambda: {429, 500, 502, 503, 504}) + retry_on_timeout: bool = True + retry_on_connection_error: bool = True + + +def compute_dapi_signature( + method: str, + path: str, + timestamp: str, + secret: str, + body: Optional[bytes] = None, +) -> str: + """ + Compute HMAC-SHA256 signature for DAPI authentication. + + Signs: "{method}\\n{path}\\n{timestamp}\\n{body_hash}" + """ + if body: + body_hash = hashlib.sha256(body).hexdigest() + else: + body_hash = "" + + string_to_sign = f"{method}\n{path}\n{timestamp}\n{body_hash}" + + return hmac.new( + secret.encode("utf-8"), + string_to_sign.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +class DAPISigner: + """Handles DAPI signature generation for outgoing requests.""" + + def __init__(self, api_key: str, api_secret: str): + self.api_key = api_key + self.api_secret = api_secret + + def sign_request( + self, + method: str, + path: str, + body: Optional[bytes] = None, + ) -> dict[str, str]: + """ + Generate DAPI authentication headers. + + Returns dict with X-DAPI-Key, X-DAPI-Timestamp, X-DAPI-Signature. + """ + timestamp = str(time.time()) + signature = compute_dapi_signature( + method=method.upper(), + path=path, + timestamp=timestamp, + secret=self.api_secret, + body=body, + ) + + return { + "X-DAPI-Key": self.api_key, + "X-DAPI-Timestamp": timestamp, + "X-DAPI-Signature": signature, + } class SageHttpClient: - """Async HTTP client for calling upstream Sage APIs. + """ + Async HTTP client with DAPI signature support. - Handles connection pooling, authentication headers, and - retry logic for transient failures. + Uses aiohttp under the hood with connection pooling, retry logic, + and automatic DAPI header injection. + + Args: + base_url: Base URL for all requests (e.g., "https://api.example.com"). + api_key: DAPI API key for request signing. + api_secret: DAPI API secret for request signing. + timeout: Request timeout in seconds. + max_retries: Maximum number of retries on transient failures. + backoff_factor: Exponential backoff multiplier. + max_connections: Maximum number of connections in the pool. + headers: Additional default headers to include in every request. + signer: Optional custom DAPISigner instance. If None, one is created from api_key/api_secret. + + Usage: + async with SageHttpClient(base_url="...", api_key="...", api_secret="...") as client: + resp = await client.get("/health") + resp = await client.post("/v1/chat", json={"msg": "hi"}) """ def __init__( self, - base_url: str = 'http://127.0.0.1:9180', - dapi_key: str = '', - dapi_secret: str = '', - timeout: float = 30.0, - max_retries: int = 3, - ) -> None: - self.base_url = base_url.rstrip('/') - self.dapi_key = dapi_key - self.dapi_secret = dapi_secret + base_url: str = "", + api_key: str = "", + api_secret: str = "", + timeout: float = DEFAULT_TIMEOUT, + max_retries: int = DEFAULT_MAX_RETRIES, + backoff_factor: float = DEFAULT_BACKOFF_FACTOR, + max_connections: int = DEFAULT_MAX_CONNECTIONS, + headers: Optional[dict[str, str]] = None, + signer: Optional[DAPISigner] = None, + auto_sign: bool = True, + ): + self.base_url = base_url.rstrip("/") self.timeout = timeout self.max_retries = max_retries + self.backoff_factor = backoff_factor + self.default_headers = headers or {} + self.auto_sign = auto_sign - def _build_headers(self) -> dict[str, str]: - """Build request headers with DAPI authentication. + if signer is not None: + self.signer = signer + else: + self.signer = DAPISigner(api_key=api_key, api_secret=api_secret) - TODO: Implement actual DAPI signature generation. + # Internal state + self._session: Optional[aiohttp.ClientSession] = None + self._connector: Optional[aiohttp.TCPConnector] = None + self._connector_limit = max_connections + self._closed = False + + def _build_url(self, path: str) -> str: + """Build full URL from base_url and path.""" + if path.startswith("http://") or path.startswith("https://"): + return path + return f"{self.base_url}/{path.lstrip('/')}" + + def _get_relative_path(self, path: str) -> str: + """Extract the relative path for signing purposes.""" + if path.startswith("http://") or path.startswith("https://"): + from urllib.parse import urlparse + + parsed = urlparse(path) + return parsed.path + return path + + async def _get_session(self) -> aiohttp.ClientSession: + """Get or create the aiohttp session with connection pooling.""" + if self._session is None or self._session.closed: + self._connector = aiohttp.TCPConnector( + limit=self._connector_limit, + limit_per_host=self._connector_limit, + ttl_dns_cache=300, + keepalive_timeout=30, + ) + self._session = aiohttp.ClientSession( + connector=self._connector, + timeout=ClientTimeout(total=self.timeout), + ) + return self._session + + async def _sign_and_prepare( + self, + method: str, + path: str, + headers: Optional[dict[str, str]] = None, + data: Any = None, + json_body: Any = None, + ) -> tuple[str, dict[str, str], Optional[bytes]]: """ - import time - import hashlib - import hmac + Prepare request with DAPI signature headers. - timestamp = str(int(time.time())) - string_to_sign = f'{self.dapi_key}:{timestamp}' - signature = hmac.new( - self.dapi_secret.encode('utf-8') if self.dapi_secret else b'', - string_to_sign.encode('utf-8'), - hashlib.sha256, - ).hexdigest() + Returns (url, merged_headers, raw_body_bytes). + """ + url = self._build_url(path) + relative_path = self._get_relative_path(path) - return { - 'Content-Type': 'application/json', - 'X-DAPI-Key': self.dapi_key, - 'X-DAPI-Timestamp': timestamp, - 'X-DAPI-Signature': signature, + merged_headers = {**self.default_headers} + if headers: + merged_headers.update(headers) + + # Determine body for signing + raw_body: Optional[bytes] = None + if json_body is not None: + import json + + raw_body = json.dumps(json_body).encode("utf-8") + merged_headers.setdefault("Content-Type", "application/json") + elif data is not None: + if isinstance(data, bytes): + raw_body = data + elif isinstance(data, str): + raw_body = data.encode("utf-8") + else: + # Form data - sign the encoded form + from urllib.parse import urlencode + + raw_body = urlencode(data).encode("utf-8") + + # Add DAPI signature headers if auto-sign is enabled and signer has credentials + if self.auto_sign and self.signer.api_key and self.signer.api_secret: + dapi_headers = self.signer.sign_request( + method=method, + path=relative_path, + body=raw_body, + ) + merged_headers.update(dapi_headers) + + return url, merged_headers, raw_body + + async def _execute_with_retry( + self, + method: str, + url: str, + headers: dict[str, str], + **kwargs: Any, + ) -> aiohttp.ClientResponse: + """ + Execute request with retry and exponential backoff. + """ + session = await self._get_session() + last_response: Optional[aiohttp.ClientResponse] = None + last_exception: Optional[Exception] = None + + for attempt in range(self.max_retries + 1): + try: + response = await session.request( + method=method, + url=url, + headers=headers, + **kwargs, + ) + + # Check if we should retry based on status + if response.status in {429, 500, 502, 503, 504}: + last_response = response + if attempt < self.max_retries: + wait_time = self.backoff_factor * (2 ** attempt) + logger.warning( + "HTTP %s on %s %s, retrying in %.1fs (attempt %d/%d)", + response.status, + method, + url, + wait_time, + attempt + 1, + self.max_retries, + ) + try: + response.release() + except Exception: + pass + await self._async_sleep(wait_time) + continue + else: + # Retries exhausted - raise an error + raise aiohttp.ClientResponseError( + request_info=response.request_info, + history=response.history, + status=response.status, + message=f"HTTP {response.status} after {self.max_retries + 1} attempts", + ) + + return response + + except aiohttp.ServerTimeoutError as e: + last_exception = e + if attempt < self.max_retries: + wait_time = self.backoff_factor * (2 ** attempt) + logger.warning( + "Timeout on %s %s, retrying in %.1fs (attempt %d/%d)", + method, + url, + wait_time, + attempt + 1, + self.max_retries, + ) + await self._async_sleep(wait_time) + continue + + except (aiohttp.ClientConnectionError, aiohttp.ClientOSError) as e: + last_exception = e + if attempt < self.max_retries: + wait_time = self.backoff_factor * (2 ** attempt) + logger.warning( + "Connection error on %s %s, retrying in %.1fs (attempt %d/%d)", + method, + url, + wait_time, + attempt + 1, + self.max_retries, + ) + await self._async_sleep(wait_time) + continue + + except Exception: + raise + + # All retries exhausted + if last_response: + return last_response + if last_exception: + raise last_exception + raise RuntimeError(f"Request failed after {self.max_retries + 1} attempts") + + @staticmethod + async def _async_sleep(seconds: float) -> None: + """Non-blocking sleep.""" + import asyncio + + await asyncio.sleep(seconds) + + async def request( + self, + method: str, + path: str, + *, + headers: Optional[dict[str, str]] = None, + data: Any = None, + json: Any = None, + params: Optional[dict[str, Any]] = None, + timeout: Optional[float] = None, + allow_redirects: bool = True, + ) -> aiohttp.ClientResponse: + """ + Send an HTTP request with DAPI signing and retry. + + Args: + method: HTTP method (GET, POST, PUT, DELETE, PATCH). + path: URL path or full URL. + headers: Additional request headers. + data: Form data or raw bytes. + json: JSON-serializable body (automatically encoded). + params: Query string parameters. + timeout: Override timeout for this request. + allow_redirects: Whether to follow redirects. + + Returns: + aiohttp.ClientResponse + """ + url, merged_headers, raw_body = await self._sign_and_prepare( + method=method, + path=path, + headers=headers, + data=data, + json_body=json, + ) + + request_kwargs: dict[str, Any] = { + "allow_redirects": allow_redirects, } - async def get( - self, - path: str, - params: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Send a GET request to the upstream Sage API. + if raw_body is not None: + if json is not None: + request_kwargs["data"] = raw_body + else: + request_kwargs["data"] = raw_body + elif json is not None: + import json as _json - Args: - path: API path (relative to base_url). - params: Optional query parameters. - headers: Optional additional headers. + request_kwargs["data"] = _json.dumps(json).encode("utf-8") - Returns: - Parsed JSON response. - """ - url = f'{self.base_url}{path}' - request_headers = {**self._build_headers(), **(headers or {})} + if data is not None and json is None: + request_kwargs["data"] = data - debug(f'HTTP GET {url} params={params}') + if params: + request_kwargs["params"] = params - # TODO: Replace with actual async HTTP implementation - # using aiohttp or the framework's built-in HTTP client. - # This is a placeholder that will be filled in once the - # specific HTTP library choice is confirmed. - raise NotImplementedError( - 'SageHttpClient.get: HTTP library not yet integrated. ' - 'Implement with aiohttp or framework HTTP client.' + # Per-request timeout override + if timeout is not None: + request_kwargs["timeout"] = ClientTimeout(total=timeout) + + return await self._execute_with_retry( + method=method.upper(), + url=url, + headers=merged_headers, + **request_kwargs, ) - async def post( - self, - path: str, - data: dict[str, Any] | None = None, - headers: dict[str, str] | None = None, - ) -> Any: - """Send a POST request to the upstream Sage API. + async def get(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse: + """Send a GET request.""" + return await self.request("GET", path, **kwargs) - Args: - path: API path (relative to base_url). - data: JSON body data. - headers: Optional additional headers. + async def post(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse: + """Send a POST request.""" + return await self.request("POST", path, **kwargs) - Returns: - Parsed JSON response. - """ - url = f'{self.base_url}{path}' - request_headers = {**self._build_headers(), **(headers or {})} + async def put(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse: + """Send a PUT request.""" + return await self.request("PUT", path, **kwargs) - debug(f'HTTP POST {url}') + async def delete(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse: + """Send a DELETE request.""" + return await self.request("DELETE", path, **kwargs) - # TODO: Replace with actual async HTTP implementation. - raise NotImplementedError( - 'SageHttpClient.post: HTTP library not yet integrated. ' - 'Implement with aiohttp or framework HTTP client.' - ) + async def patch(self, path: str, **kwargs: Any) -> aiohttp.ClientResponse: + """Send a PATCH request.""" + return await self.request("PATCH", path, **kwargs) + + async def close(self) -> None: + """Close the HTTP client and release resources.""" + if self._session and not self._session.closed: + await self._session.close() + self._closed = True + + async def __aenter__(self) -> "SageHttpClient": + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + await self.close() + + def __del__(self) -> None: + # Best-effort cleanup; prefer using async context manager + if not self._closed and self._session and not self._session.closed: + logger.warning( + "SageHttpClient was not properly closed. " + "Use 'async with SageHttpClient(...)' for proper cleanup." + ) diff --git a/sync/cache_tables.sql b/sync/cache_tables.sql new file mode 100644 index 0000000..c96b9b9 --- /dev/null +++ b/sync/cache_tables.sql @@ -0,0 +1,122 @@ +-- ============================================================================= +-- SageAPI Cache Tables DDL +-- These tables store synchronized data from the Sage database. +-- Run against the sageapi database. +-- ============================================================================= + +-- Checkpoint table: tracks the last sync timestamp for each module +CREATE TABLE IF NOT EXISTS sync_state ( + state_key VARCHAR(64) NOT NULL PRIMARY KEY, + last_sync_ts VARCHAR(64) DEFAULT NULL, + created_at VARCHAR(32) NOT NULL, + updated_at VARCHAR(32) NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================= +-- users_cache: synced from Sage users / organi / organization tables +-- ============================================================================= +CREATE TABLE IF NOT EXISTS users_cache ( + user_id BIGINT NOT NULL PRIMARY KEY, + username VARCHAR(128) DEFAULT NULL, + email VARCHAR(256) DEFAULT NULL, + user_status INT DEFAULT 0, + user_created_at VARCHAR(32) DEFAULT NULL, + user_updated_at VARCHAR(32) DEFAULT NULL, + organi_id BIGINT DEFAULT NULL, + organi_name VARCHAR(256) DEFAULT NULL, + organi_parent_id BIGINT DEFAULT NULL, + org_id BIGINT DEFAULT NULL, + org_name VARCHAR(256) DEFAULT NULL, + org_type VARCHAR(64) DEFAULT NULL, + org_status INT DEFAULT 0, + org_updated_at VARCHAR(32) DEFAULT NULL, + synced_at VARCHAR(32) NOT NULL, + UNIQUE KEY uk_organi (organi_id), + KEY idx_updated (user_updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================= +-- pricing_cache: synced from Sage pricing_program / pricing_program_timing +-- ============================================================================= +CREATE TABLE IF NOT EXISTS pricing_cache ( + program_id BIGINT NOT NULL, + program_name VARCHAR(256) DEFAULT NULL, + program_code VARCHAR(128) DEFAULT NULL, + program_type VARCHAR(64) DEFAULT NULL, + program_status INT DEFAULT 0, + description TEXT DEFAULT NULL, + program_created_at VARCHAR(32) DEFAULT NULL, + program_updated_at VARCHAR(32) DEFAULT NULL, + timing_id BIGINT DEFAULT NULL, + start_time VARCHAR(32) DEFAULT NULL, + end_time VARCHAR(32) DEFAULT NULL, + duration INT DEFAULT NULL, + repeat_rule VARCHAR(256) DEFAULT NULL, + timezone VARCHAR(64) DEFAULT NULL, + timing_status INT DEFAULT 0, + timing_updated_at VARCHAR(32) DEFAULT NULL, + synced_at VARCHAR(32) NOT NULL, + PRIMARY KEY (program_id, timing_id), + KEY idx_program_updated (program_updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================= +-- uapi_cache: synced from Sage uapi / upapp tables +-- ============================================================================= +CREATE TABLE IF NOT EXISTS uapi_cache ( + uapi_id BIGINT NOT NULL, + api_name VARCHAR(256) DEFAULT NULL, + api_path VARCHAR(512) DEFAULT NULL, + api_method VARCHAR(16) DEFAULT 'GET', + api_version VARCHAR(32) DEFAULT NULL, + api_desc TEXT DEFAULT NULL, + uapi_status INT DEFAULT 0, + auth_required TINYINT DEFAULT 0, + uapi_created_at VARCHAR(32) DEFAULT NULL, + uapi_updated_at VARCHAR(32) DEFAULT NULL, + upapp_id BIGINT DEFAULT NULL, + app_name VARCHAR(256) DEFAULT NULL, + app_code VARCHAR(128) DEFAULT NULL, + app_type VARCHAR(64) DEFAULT NULL, + app_desc TEXT DEFAULT NULL, + app_owner VARCHAR(128) DEFAULT NULL, + upapp_status INT DEFAULT 0, + upapp_updated_at VARCHAR(32) DEFAULT NULL, + synced_at VARCHAR(32) NOT NULL, + PRIMARY KEY (uapi_id, upapp_id), + KEY idx_uapi_updated (uapi_updated_at), + KEY idx_upapp_id (upapp_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================================================= +-- llmage_cache: synced from Sage llm / llmcatelog / llm_api_map tables +-- ============================================================================= +CREATE TABLE IF NOT EXISTS llmage_cache ( + llm_id BIGINT NOT NULL, + model_name VARCHAR(256) DEFAULT NULL, + model_version VARCHAR(64) DEFAULT NULL, + provider VARCHAR(128) DEFAULT NULL, + model_type VARCHAR(64) DEFAULT NULL, + llm_status INT DEFAULT 0, + llm_description TEXT DEFAULT NULL, + llm_created_at VARCHAR(32) DEFAULT NULL, + llm_updated_at VARCHAR(32) DEFAULT NULL, + catelog_id BIGINT DEFAULT NULL, + catelog_name VARCHAR(256) DEFAULT NULL, + catelog_code VARCHAR(128) DEFAULT NULL, + catelog_sort INT DEFAULT 0, + catelog_status INT DEFAULT 0, + catelog_updated_at VARCHAR(32) DEFAULT NULL, + api_map_id BIGINT DEFAULT NULL, + api_name VARCHAR(256) DEFAULT NULL, + api_endpoint VARCHAR(512) DEFAULT NULL, + api_version VARCHAR(32) DEFAULT NULL, + auth_type VARCHAR(32) DEFAULT NULL, + rate_limit INT DEFAULT NULL, + api_map_status INT DEFAULT 0, + api_map_updated_at VARCHAR(32) DEFAULT NULL, + synced_at VARCHAR(32) NOT NULL, + PRIMARY KEY (llm_id, catelog_id, api_map_id), + KEY idx_llm_updated (llm_updated_at), + KEY idx_catelog_id (catelog_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d4839a6 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests package diff --git a/tests/__pycache__/__init__.cpython-310.pyc b/tests/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bb35765e2d3ed697fbd6a216e73e72f7e25ec132 GIT binary patch literal 133 zcmd1j<>g`k0*(&eECC?>7{oyaj6jY95Erumi4=xl22Do4l?+87VFd9@Lq8)wH&s6) zwJ0~WI5AVdI59mnu^>~wB(=DtSU)~KGcU6wK3=b&@)n0pZhlH>PO2TqgkmNj!NLFl Dcy=2r literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_dapi_auth.cpython-310-pytest-9.0.3.pyc b/tests/__pycache__/test_dapi_auth.cpython-310-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58adb5312426a5e90d0ec88d8809d887817a3ce6 GIT binary patch literal 26609 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 F< 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 NysdaN_LIZg{|_L(VFLgF literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_http_client.cpython-310-pytest-9.0.3.pyc b/tests/__pycache__/test_http_client.cpython-310-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a19b98b3b935805f3f22cd9b27aaab68cebebb1 GIT binary patch literal 20311 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 str: + """Helper to create a valid DAPI signature.""" + return compute_dapi_signature(method, path, timestamp, secret, body if body else None) + + +# --------------------------------------------------------------------------- +# compute_dapi_signature tests +# --------------------------------------------------------------------------- + +class TestComputeDapiSignature: + def test_basic_signature(self): + sig = compute_dapi_signature("GET", "/api/test", "1700000000.0", "my-secret") + assert isinstance(sig, str) + assert len(sig) == 64 # SHA-256 hex length + + def test_signature_with_body(self): + body = b'{"key": "value"}' + sig = compute_dapi_signature("POST", "/api/test", "1700000000.0", "my-secret", body) + assert isinstance(sig, str) + assert len(sig) == 64 + + def test_signature_deterministic(self): + sig1 = compute_dapi_signature("GET", "/path", "123.0", "secret") + sig2 = compute_dapi_signature("GET", "/path", "123.0", "secret") + assert sig1 == sig2 + + def test_different_body_different_signature(self): + sig1 = compute_dapi_signature("POST", "/path", "123.0", "secret", b"body1") + sig2 = compute_dapi_signature("POST", "/path", "123.0", "secret", b"body2") + assert sig1 != sig2 + + def test_empty_body_same_as_no_body(self): + sig1 = compute_dapi_signature("GET", "/path", "123.0", "secret") + sig2 = compute_dapi_signature("GET", "/path", "123.0", "secret", None) + assert sig1 == sig2 + + def test_manual_verification(self): + """Verify the signature matches the expected HMAC-SHA256 output.""" + method, path, ts, secret = "POST", "/v1/chat", "1700000000.0", "test-secret" + body = b'{"message":"hello"}' + body_hash = hashlib.sha256(body).hexdigest() + string_to_sign = f"{method}\n{path}\n{ts}\n{body_hash}" + expected = hmac.new(secret.encode(), string_to_sign.encode(), hashlib.sha256).hexdigest() + actual = compute_dapi_signature(method, path, ts, secret, body) + assert actual == expected + + +# --------------------------------------------------------------------------- +# verify_timestamp tests +# --------------------------------------------------------------------------- + +class TestVerifyTimestamp: + def test_valid_timestamp(self): + ts = str(time.time()) + assert verify_timestamp(ts) is True + + def test_expired_timestamp(self): + ts = str(time.time() - 600) # 10 minutes ago + assert verify_timestamp(ts) is False + + def test_future_timestamp(self): + ts = str(time.time() + 600) # 10 minutes in future + assert verify_timestamp(ts) is False + + def test_invalid_timestamp(self): + assert verify_timestamp("not-a-number") is False + assert verify_timestamp("") is False + + def test_custom_tolerance(self): + ts = str(time.time() - 10) + assert verify_timestamp(ts, tolerance_sec=5) is False + assert verify_timestamp(ts, tolerance_sec=15) is True + + +# --------------------------------------------------------------------------- +# DapiKeyRecord tests +# --------------------------------------------------------------------------- + +class TestDapiKeyRecord: + def test_active_key(self): + rec = DapiKeyRecord(id=1, apikey="k1", secret="s1", status="active", expire_date=None) + assert rec.is_active is True + + def test_inactive_key(self): + rec = DapiKeyRecord(id=1, apikey="k1", secret="s1", status="inactive", expire_date=None) + assert rec.is_active is False + + def test_not_expired_when_no_expiry(self): + rec = DapiKeyRecord(id=1, apikey="k1", secret="s1", status="active", expire_date=None) + assert rec.is_expired is False + + def test_expired_with_past_date(self): + past = datetime(2020, 1, 1, tzinfo=timezone.utc) + rec = DapiKeyRecord(id=1, apikey="k1", secret="s1", status="active", expire_date=past) + assert rec.is_expired is True + + def test_not_expired_with_future_date(self): + future = datetime(2099, 1, 1, tzinfo=timezone.utc) + rec = DapiKeyRecord(id=1, apikey="k1", secret="s1", status="active", expire_date=future) + assert rec.is_expired is False + + def test_expired_with_iso_string_past(self): + rec = DapiKeyRecord(id=1, apikey="k1", secret="s1", status="active", expire_date="2020-01-01T00:00:00Z") + assert rec.is_expired is True + + def test_not_expired_with_iso_string_future(self): + rec = DapiKeyRecord(id=1, apikey="k1", secret="s1", status="active", expire_date="2099-01-01T00:00:00Z") + assert rec.is_expired is False + + +# --------------------------------------------------------------------------- +# lookup_api_key tests +# --------------------------------------------------------------------------- + +class TestLookupApiKey: + @pytest.fixture(autouse=True) + def mock_sage_modules(self): + """Create mock ahserver and sqlor modules for testing.""" + import sys + import types + + # Create fake modules + ahserver = types.ModuleType("ahserver") + ahserver_serverenv = types.ModuleType("ahserver.serverenv") + ahserver_serverenv.ServerEnv = MagicMock + ahserver.serverenv = ahserver_serverenv + + sqlor = types.ModuleType("sqlor") + sqlor_dbpools = types.ModuleType("sqlor.dbpools") + + mock_ctx = AsyncMock() + mock_sor = AsyncMock() + mock_sor.R = AsyncMock(return_value=[]) + mock_ctx.__aenter__ = AsyncMock(return_value=mock_sor) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + sqlor_dbpools.get_sor_context = MagicMock(return_value=mock_ctx) + sqlor.dbpools = sqlor_dbpools + + sys.modules["ahserver"] = ahserver + sys.modules["ahserver.serverenv"] = ahserver_serverenv + sys.modules["sqlor"] = sqlor + sys.modules["sqlor.dbpools"] = sqlor_dbpools + + yield mock_ctx, mock_sor + + # Cleanup + for mod in ["ahserver", "ahserver.serverenv", "sqlor", "sqlor.dbpools"]: + if mod in sys.modules: + del sys.modules[mod] + + @pytest.mark.asyncio + async def test_lookup_found(self, mock_sage_modules): + mock_ctx, mock_sor = mock_sage_modules + mock_rec = { + "id": 1, + "apikey": "test-key", + "secret": "test-secret", + "status": "active", + "expire_date": "2099-01-01T00:00:00Z", + "description": "Test key", + } + mock_sor.R.return_value = [mock_rec] + + result = await lookup_api_key("test-key") + + assert result is not None + assert result.apikey == "test-key" + assert result.secret == "test-secret" + assert result.is_active is True + + @pytest.mark.asyncio + async def test_lookup_not_found(self, mock_sage_modules): + mock_ctx, mock_sor = mock_sage_modules + mock_sor.R.return_value = [] + + result = await lookup_api_key("nonexistent") + + assert result is None + + +# --------------------------------------------------------------------------- +# authenticate_request tests +# --------------------------------------------------------------------------- + +class TestAuthenticateRequest: + def _make_request(self, headers: dict, body: bytes = b"", method: str = "GET", path: str = "/test") -> Request: + scope = { + "type": "http", + "method": method, + "path": path, + "headers": [(k.lower().encode(), v.encode()) for k, v in headers.items()], + "query_string": b"", + } + + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + return Request(scope, receive) + + @pytest.mark.asyncio + async def test_missing_api_key_header(self): + req = self._make_request({ + "X-DAPI-Timestamp": str(time.time()), + "X-DAPI-Signature": "abc", + }) + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 401 + assert "X-DAPI-Key" in exc.value.detail + + @pytest.mark.asyncio + async def test_missing_timestamp_header(self): + req = self._make_request({ + "X-DAPI-Key": "test-key", + "X-DAPI-Signature": "abc", + }) + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 401 + assert "X-DAPI-Timestamp" in exc.value.detail + + @pytest.mark.asyncio + async def test_missing_signature_header(self): + req = self._make_request({ + "X-DAPI-Key": "test-key", + "X-DAPI-Timestamp": str(time.time()), + }) + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 401 + assert "X-DAPI-Signature" in exc.value.detail + + @pytest.mark.asyncio + async def test_expired_timestamp(self): + ts = str(time.time() - 600) + req = self._make_request({ + "X-DAPI-Key": "test-key", + "X-DAPI-Timestamp": ts, + "X-DAPI-Signature": "abc", + }) + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 401 + assert "timestamp" in exc.value.detail.lower() + + @pytest.mark.asyncio + async def test_invalid_api_key(self): + ts = str(time.time()) + req = self._make_request({ + "X-DAPI-Key": "bad-key", + "X-DAPI-Timestamp": ts, + "X-DAPI-Signature": "abc", + }) + + with patch("sageapi.middleware.dapi_auth.lookup_api_key", return_value=None): + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 401 + assert "Invalid API key" in exc.value.detail + + @pytest.mark.asyncio + async def test_inactive_key(self): + ts = str(time.time()) + req = self._make_request({ + "X-DAPI-Key": "test-key", + "X-DAPI-Timestamp": ts, + "X-DAPI-Signature": "abc", + }) + key_rec = DapiKeyRecord(id=1, apikey="test-key", secret="secret", status="inactive", expire_date=None) + + with patch("sageapi.middleware.dapi_auth.lookup_api_key", return_value=key_rec): + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 403 + assert "inactive" in exc.value.detail.lower() + + @pytest.mark.asyncio + async def test_expired_key(self): + ts = str(time.time()) + req = self._make_request({ + "X-DAPI-Key": "test-key", + "X-DAPI-Timestamp": ts, + "X-DAPI-Signature": "abc", + }) + key_rec = DapiKeyRecord( + id=1, apikey="test-key", secret="secret", status="active", + expire_date=datetime(2020, 1, 1, tzinfo=timezone.utc), + ) + + with patch("sageapi.middleware.dapi_auth.lookup_api_key", return_value=key_rec): + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 403 + assert "expired" in exc.value.detail.lower() + + @pytest.mark.asyncio + async def test_invalid_signature(self): + ts = str(time.time()) + body = b'{"test": "data"}' + req = self._make_request({ + "X-DAPI-Key": "test-key", + "X-DAPI-Timestamp": ts, + "X-DAPI-Signature": "invalid-signature", + }, body=body, method="POST") + + key_rec = DapiKeyRecord(id=1, apikey="test-key", secret="real-secret", status="active", expire_date=None) + + with patch("sageapi.middleware.dapi_auth.lookup_api_key", return_value=key_rec): + with pytest.raises(DapiAuthError) as exc: + await authenticate_request(req) + assert exc.value.status_code == 401 + assert "Invalid signature" in exc.value.detail + + @pytest.mark.asyncio + async def test_valid_authentication(self): + ts = str(time.time()) + secret = "my-secret" + body = b'{"test": "data"}' + sig = compute_dapi_signature("POST", "/test", ts, secret, body) + + req = self._make_request({ + "X-DAPI-Key": "test-key", + "X-DAPI-Timestamp": ts, + "X-DAPI-Signature": sig, + }, body=body, method="POST") + + key_rec = DapiKeyRecord(id=1, apikey="test-key", secret=secret, status="active", expire_date=None) + + with patch("sageapi.middleware.dapi_auth.lookup_api_key", return_value=key_rec): + result = await authenticate_request(req) + + assert result.apikey == "test-key" + assert result.secret == secret + + +# --------------------------------------------------------------------------- +# DapiAuthMiddleware tests +# --------------------------------------------------------------------------- + +class TestDapiAuthMiddleware: + @pytest.fixture(autouse=True) + def mock_sage_modules(self): + """Create mock ahserver and sqlor modules.""" + import sys + import types + + ahserver = types.ModuleType("ahserver") + ahserver_serverenv = types.ModuleType("ahserver.serverenv") + ahserver_serverenv.ServerEnv = MagicMock + ahserver.serverenv = ahserver_serverenv + + sqlor = types.ModuleType("sqlor") + sqlor_dbpools = types.ModuleType("sqlor.dbpools") + mock_sor = AsyncMock() + mock_sor.R = AsyncMock(return_value=[]) + mock_ctx = AsyncMock() + mock_ctx.__aenter__ = AsyncMock(return_value=mock_sor) + mock_ctx.__aexit__ = AsyncMock(return_value=False) + sqlor_dbpools.get_sor_context = MagicMock(return_value=mock_ctx) + sqlor.dbpools = sqlor_dbpools + + sys.modules["ahserver"] = ahserver + sys.modules["ahserver.serverenv"] = ahserver_serverenv + sys.modules["sqlor"] = sqlor + sys.modules["sqlor.dbpools"] = sqlor_dbpools + + yield + + for mod in ["ahserver", "ahserver.serverenv", "sqlor", "sqlor.dbpools"]: + if mod in sys.modules: + del sys.modules[mod] + + def _build_test_app(self, exclude_paths=None, tolerance_sec=DEFAULT_TIMESTAMP_TOLERANCE_SEC): + from starlette.applications import Starlette + from starlette.responses import PlainTextResponse + from starlette.routing import Route + + async def protected(request: Request): + return PlainTextResponse("OK - authenticated") + + async def health(request: Request): + return PlainTextResponse("healthy") + + app = Starlette( + routes=[ + Route("/protected", endpoint=protected, methods=["GET"]), + Route("/health", endpoint=health, methods=["GET"]), + ] + ) + + app.add_middleware( + DapiAuthMiddleware, + exclude_paths=exclude_paths or ["/health"], + tolerance_sec=tolerance_sec, + ) + return app + + def test_unauthenticated_request_rejected(self): + app = self._build_test_app() + client = TestClient(app) + resp = client.get("/protected") + assert resp.status_code == 401 + assert "error" in resp.json() + + def test_excluded_path_bypasses_auth(self): + app = self._build_test_app() + client = TestClient(app) + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.text == "healthy" + + def test_valid_request_accepted(self): + ts = str(time.time()) + secret = "test-secret" + sig = compute_dapi_signature("GET", "/protected", ts, secret) + + key_rec = DapiKeyRecord(id=1, apikey="valid-key", secret=secret, status="active", expire_date=None) + + app = self._build_test_app() + client = TestClient(app) + + with patch("sageapi.middleware.dapi_auth.lookup_api_key", return_value=key_rec): + resp = client.get( + "/protected", + headers={ + "X-DAPI-Key": "valid-key", + "X-DAPI-Timestamp": ts, + "X-DAPI-Signature": sig, + }, + ) + + assert resp.status_code == 200 + assert resp.text == "OK - authenticated" diff --git a/tests/test_http_client.py b/tests/test_http_client.py new file mode 100644 index 0000000..8d7c4a4 --- /dev/null +++ b/tests/test_http_client.py @@ -0,0 +1,288 @@ +"""Tests for sageapi.utils.http_client""" + +import hashlib +import hmac +import json +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import aiohttp +import pytest + +from sageapi.utils.http_client import ( + DAPISigner, + SageHttpClient, + RetryConfig, + compute_dapi_signature, +) + + +# --------------------------------------------------------------------------- +# compute_dapi_signature tests (also imported from middleware) +# --------------------------------------------------------------------------- + +class TestComputeDapiSignature: + def test_basic(self): + sig = compute_dapi_signature("GET", "/api/test", "1700000000.0", "secret") + assert len(sig) == 64 + + def test_with_body(self): + body = b'{"msg":"hi"}' + sig = compute_dapi_signature("POST", "/api/test", "1700000000.0", "secret", body) + assert len(sig) == 64 + + def test_deterministic(self): + sig1 = compute_dapi_signature("GET", "/path", "123.0", "secret") + sig2 = compute_dapi_signature("GET", "/path", "123.0", "secret") + assert sig1 == sig2 + + +# --------------------------------------------------------------------------- +# DAPISigner tests +# --------------------------------------------------------------------------- + +class TestDAPISigner: + def test_sign_request(self): + signer = DAPISigner(api_key="my-key", api_secret="my-secret") + headers = signer.sign_request("GET", "/test") + + assert "X-DAPI-Key" in headers + assert "X-DAPI-Timestamp" in headers + assert "X-DAPI-Signature" in headers + assert headers["X-DAPI-Key"] == "my-key" + assert headers["X-DAPI-Timestamp"] # should be a valid timestamp string + + def test_sign_request_with_body(self): + signer = DAPISigner(api_key="k", api_secret="s") + body = b'{"test": true}' + headers = signer.sign_request("POST", "/v1/chat", body) + + assert headers["X-DAPI-Key"] == "k" + # Verify timestamp is recent + ts = float(headers["X-DAPI-Timestamp"]) + assert abs(time.time() - ts) < 2 + + def test_sign_request_produces_valid_signature(self): + signer = DAPISigner(api_key="k", api_secret="secret") + body = b'hello' + headers = signer.sign_request("POST", "/path", body) + + expected = compute_dapi_signature("POST", "/path", headers["X-DAPI-Timestamp"], "secret", body) + assert headers["X-DAPI-Signature"] == expected + + def test_sign_request_uppercases_method(self): + signer = DAPISigner(api_key="k", api_secret="s") + headers = signer.sign_request("get", "/path") + expected = compute_dapi_signature("GET", "/path", headers["X-DAPI-Timestamp"], "s") + assert headers["X-DAPI-Signature"] == expected + + +# --------------------------------------------------------------------------- +# RetryConfig tests +# --------------------------------------------------------------------------- + +class TestRetryConfig: + def test_defaults(self): + config = RetryConfig() + assert config.max_retries == 3 + assert config.backoff_factor == 0.5 + assert 429 in config.retry_on_status + assert 500 in config.retry_on_status + + def test_custom(self): + config = RetryConfig(max_retries=5, backoff_factor=1.0) + assert config.max_retries == 5 + assert config.backoff_factor == 1.0 + + +# --------------------------------------------------------------------------- +# SageHttpClient tests +# --------------------------------------------------------------------------- + +class TestSageHttpClient: + def test_init_defaults(self): + client = SageHttpClient( + base_url="https://api.example.com", + api_key="key", + api_secret="secret", + ) + assert client.base_url == "https://api.example.com" + assert client.signer.api_key == "key" + assert client.signer.api_secret == "secret" + assert client.auto_sign is True + + def test_init_with_custom_signer(self): + signer = DAPISigner(api_key="k", api_secret="s") + client = SageHttpClient(base_url="https://api.example.com", signer=signer) + assert client.signer is signer + + def test_init_without_auto_sign(self): + client = SageHttpClient( + base_url="https://api.example.com", + auto_sign=False, + ) + assert client.auto_sign is False + + def test_build_url(self): + client = SageHttpClient(base_url="https://api.example.com") + assert client._build_url("/v1/test") == "https://api.example.com/v1/test" + assert client._build_url("v1/test") == "https://api.example.com/v1/test" + # Full URL should pass through + assert client._build_url("https://other.com/path") == "https://other.com/path" + + def test_get_relative_path(self): + client = SageHttpClient(base_url="https://api.example.com") + assert client._get_relative_path("/v1/chat") == "/v1/chat" + assert client._get_relative_path("https://api.example.com/v1/chat?page=1") == "/v1/chat" + + def test_sign_and_prepare_adds_dapi_headers(self): + client = SageHttpClient( + base_url="https://api.example.com", + api_key="k", + api_secret="s", + ) + + import asyncio + + url, headers, body = asyncio.get_event_loop().run_until_complete( + client._sign_and_prepare("GET", "/test") + ) + + assert "X-DAPI-Key" in headers + assert "X-DAPI-Timestamp" in headers + assert "X-DAPI-Signature" in headers + assert headers["X-DAPI-Key"] == "k" + + def test_sign_and_prepare_with_json_body(self): + client = SageHttpClient( + base_url="https://api.example.com", + api_key="k", + api_secret="s", + ) + + import asyncio + + url, headers, body = asyncio.get_event_loop().run_until_complete( + client._sign_and_prepare("POST", "/test", json_body={"msg": "hi"}) + ) + + assert body is not None + assert json.loads(body) == {"msg": "hi"} + assert headers.get("Content-Type") == "application/json" + + @pytest.mark.asyncio + async def test_context_manager(self): + client = SageHttpClient( + base_url="https://httpbin.org", + api_key="k", + api_secret="s", + max_retries=0, + ) + async with client as c: + assert c is client + assert client._closed is True + + @pytest.mark.asyncio + async def test_close(self): + client = SageHttpClient( + base_url="https://httpbin.org", + api_key="k", + api_secret="s", + max_retries=0, + ) + await client._get_session() # create session + await client.close() + assert client._closed is True + + @pytest.mark.asyncio + async def test_request_with_retry_on_502(self): + """Test that 502 responses trigger retries and raise after exhaustion.""" + client = SageHttpClient( + base_url="", + api_key="k", + api_secret="s", + max_retries=2, + backoff_factor=0.01, # fast for tests + auto_sign=False, + ) + + mock_response = AsyncMock() + mock_response.status = 502 + mock_response.request_info = MagicMock() + mock_response.history = [] + mock_response.release = MagicMock() + + mock_session = AsyncMock() + mock_session.request = AsyncMock(return_value=mock_response) + mock_session.closed = False + + client._session = mock_session + + with pytest.raises(aiohttp.ClientResponseError) as exc_info: + await client.request("GET", "/test") + + assert exc_info.value.status == 502 + # Should have been called 3 times (1 initial + 2 retries) + assert mock_session.request.call_count == 3 + + +class TestSageHttpClientIntegration: + """Integration tests against httpbin.org (requires network).""" + + @pytest.mark.asyncio + async def test_get_request(self): + client = SageHttpClient( + base_url="https://httpbin.org", + auto_sign=False, + max_retries=0, + timeout=10.0, + ) + async with client: + resp = await client.get("/get") + assert resp.status == 200 + data = await resp.json() + assert "url" in data + + @pytest.mark.asyncio + async def test_post_request_with_json(self): + client = SageHttpClient( + base_url="https://httpbin.org", + auto_sign=False, + max_retries=0, + timeout=10.0, + ) + async with client: + resp = await client.post("/post", json={"key": "value"}) + assert resp.status == 200 + data = await resp.json() + assert data["json"] == {"key": "value"} + + @pytest.mark.asyncio + async def test_headers_sent(self): + client = SageHttpClient( + base_url="https://httpbin.org", + headers={"X-Custom-Header": "test-value"}, + auto_sign=False, + max_retries=0, + timeout=10.0, + ) + async with client: + resp = await client.get("/headers") + assert resp.status == 200 + data = await resp.json() + assert data["headers"].get("X-Custom-Header") == "test-value" + + @pytest.mark.asyncio + async def test_query_params(self): + client = SageHttpClient( + base_url="https://httpbin.org", + auto_sign=False, + max_retries=0, + timeout=10.0, + ) + async with client: + resp = await client.get("/get", params={"foo": "bar", "baz": "qux"}) + assert resp.status == 200 + data = await resp.json() + assert data["args"]["foo"] == "bar" + assert data["args"]["baz"] == "qux"