mini-apigw 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gateway/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.1.0"
gateway/accounting.py ADDED
@@ -0,0 +1,252 @@
1
+ """Usage accounting and PostgreSQL backend"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime, timezone
9
+ from typing import Any, Dict, Optional, Sequence
10
+
11
+ from .config import AppDefinition, CostLimitConfig, DatabaseConfig
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ try:
16
+ import psycopg # type: ignore[attr-defined]
17
+ _DB_DRIVER_NAME = "psycopg"
18
+ except ImportError: # pragma: no cover - psycopg optional
19
+ try:
20
+ import psycopg2 as psycopg # type: ignore[assignment]
21
+ _DB_DRIVER_NAME = "psycopg2"
22
+ except ImportError: # pragma: no cover - psycopg optional
23
+ psycopg = None # type: ignore[assignment]
24
+ _DB_DRIVER_NAME = None
25
+
26
+ @dataclass(slots=True)
27
+ class AccountingRecord:
28
+ app_id: str
29
+ backend: str
30
+ model: str
31
+ operation: str
32
+ cost: float
33
+ prompt_tokens: Optional[int]
34
+ completion_tokens: Optional[int]
35
+ total_tokens: Optional[int]
36
+ latency_ms: Optional[int]
37
+ created_at: datetime = datetime.now(timezone.utc)
38
+
39
+ @dataclass(slots=True)
40
+ class ModelCostState:
41
+ backend: str
42
+ model: str
43
+ total_cost: float = 0.0
44
+ request_count: int = 0
45
+
46
+ @dataclass(slots=True)
47
+ class CostState:
48
+ total_cost: float = 0.0
49
+ request_count: int = 0
50
+ models: Dict[tuple[str, str], ModelCostState] = field(default_factory=dict)
51
+
52
+
53
+ class AccountingRecorder:
54
+ """Persist usage records and expose aggregates."""
55
+
56
+ def __init__(self, db_config: Optional[DatabaseConfig]):
57
+ self._db_config = db_config
58
+ self._queue: "asyncio.Queue[AccountingRecord | None]" = asyncio.Queue()
59
+ self._task: Optional[asyncio.Task[None]] = None
60
+ self._costs: Dict[str, CostState] = {}
61
+ self._cost_lock = asyncio.Lock()
62
+ self._db_lock = asyncio.Lock()
63
+ self._db_conn = None
64
+ self._db_driver_name = _DB_DRIVER_NAME
65
+ if db_config is None:
66
+ log.warning("Accounting recorder running without database; usage persisted in-memory only")
67
+ elif psycopg is None:
68
+ log.warning("Accounting recorder has database configuration but no Postgres driver (psycopg/psycopg2) is available")
69
+ else:
70
+ log.info("Accounting recorder using %s driver for Postgres persistence", self._db_driver_name)
71
+
72
+ async def start(self) -> None:
73
+ if self._task is None:
74
+ await self._bootstrap_costs()
75
+ self._task = asyncio.create_task(self._worker())
76
+
77
+ async def stop(self) -> None:
78
+ if self._task is None:
79
+ return
80
+ await self._queue.put(None)
81
+ try:
82
+ await self._task
83
+ except asyncio.CancelledError: # pragma: no cover - shutdown cancellation
84
+ pass
85
+ self._task = None
86
+ if self._db_conn is not None:
87
+ try:
88
+ self._db_conn.close()
89
+ except Exception: # pragma: no cover
90
+ pass
91
+ self._db_conn = None
92
+
93
+ async def record(self, record: AccountingRecord) -> None:
94
+ async with self._cost_lock:
95
+ state = self._costs.setdefault(record.app_id, CostState())
96
+ state.total_cost += record.cost
97
+ state.request_count += 1
98
+ key = (record.backend, record.model)
99
+ model_state = state.models.get(key)
100
+ if model_state is None:
101
+ model_state = ModelCostState(backend=record.backend, model=record.model)
102
+ state.models[key] = model_state
103
+ model_state.total_cost += record.cost
104
+ model_state.request_count += 1
105
+ await self._queue.put(record)
106
+
107
+ async def _worker(self) -> None:
108
+ try:
109
+ while True:
110
+ record = await self._queue.get()
111
+ if record is None:
112
+ break
113
+ if self._db_config is None:
114
+ continue
115
+ await self._write_record(record)
116
+ except asyncio.CancelledError: # pragma: no cover - shutdown cancellation
117
+ return
118
+
119
+ async def _write_record(self, record: AccountingRecord) -> None:
120
+ if psycopg is None:
121
+ return
122
+ async with self._db_lock:
123
+ if not self._ensure_connection():
124
+ return
125
+ try:
126
+ with self._db_conn.cursor() as cur:
127
+ cur.execute(
128
+ """
129
+ INSERT INTO requests (
130
+ app_id, backend, model, operation,
131
+ cost, prompt_tokens, completion_tokens, total_tokens, latency_ms, created_at
132
+ ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
133
+ """,
134
+ (
135
+ record.app_id,
136
+ record.backend,
137
+ record.model,
138
+ record.operation,
139
+ record.cost,
140
+ record.prompt_tokens,
141
+ record.completion_tokens,
142
+ record.total_tokens,
143
+ record.latency_ms,
144
+ record.created_at,
145
+ ),
146
+ )
147
+ except Exception as exc: # pragma: no cover
148
+ log.error("Failed to persist accounting record: %s", exc)
149
+
150
+ async def cost_total(self, app_id: str) -> CostState:
151
+ async with self._cost_lock:
152
+ state = self._costs.get(app_id)
153
+ if state is None:
154
+ return CostState()
155
+ return CostState(total_cost=state.total_cost, request_count=state.request_count)
156
+
157
+ async def over_cost_limit(self, app: AppDefinition) -> bool:
158
+ if app.cost_limit is None:
159
+ return False
160
+ state = await self.cost_total(app.app_id)
161
+ return state.total_cost >= app.cost_limit.limit
162
+
163
+ async def snapshot(self) -> Dict[str, CostState]:
164
+ async with self._cost_lock:
165
+ return {
166
+ app_id: CostState(
167
+ total_cost=state.total_cost,
168
+ request_count=state.request_count,
169
+ models={
170
+ key: ModelCostState(
171
+ backend=model_state.backend,
172
+ model=model_state.model,
173
+ total_cost=model_state.total_cost,
174
+ request_count=model_state.request_count,
175
+ )
176
+ for key, model_state in state.models.items()
177
+ },
178
+ )
179
+ for app_id, state in self._costs.items()
180
+ }
181
+
182
+ async def _bootstrap_costs(self) -> None:
183
+ if psycopg is None or self._db_config is None:
184
+ return
185
+ start_of_day = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
186
+ rows: Sequence[tuple[str, str, str, Any, Any]] = []
187
+ async with self._db_lock:
188
+ if not self._ensure_connection():
189
+ return
190
+ try:
191
+ with self._db_conn.cursor() as cur:
192
+ cur.execute(
193
+ """
194
+ SELECT app_id, backend, model, SUM(cost) AS total_cost, COUNT(*) AS request_count
195
+ FROM requests
196
+ WHERE created_at >= %s
197
+ GROUP BY app_id, backend, model
198
+ """,
199
+ (start_of_day,),
200
+ )
201
+ rows = cur.fetchall()
202
+ except Exception as exc: # pragma: no cover
203
+ log.error("Failed to load prior accounting state: %s", exc)
204
+ return
205
+
206
+ if not rows:
207
+ return
208
+
209
+ async with self._cost_lock:
210
+ for app_id, backend, model, total_cost, request_count in rows:
211
+ state = self._costs.setdefault(app_id, CostState())
212
+ state.total_cost += float(total_cost)
213
+ state.request_count += int(request_count)
214
+ key = (backend, model)
215
+ model_state = state.models.get(key)
216
+ if model_state is None:
217
+ model_state = ModelCostState(backend=backend, model=model)
218
+ state.models[key] = model_state
219
+ model_state.total_cost += float(total_cost)
220
+ model_state.request_count += int(request_count)
221
+
222
+ log.info("Restored accounting state for %d app/model combinations", len(rows))
223
+
224
+ def _ensure_connection(self) -> bool:
225
+ if self._db_config is None:
226
+ return False
227
+ if self._db_conn is not None:
228
+ return True
229
+ try:
230
+ self._db_conn = psycopg.connect(
231
+ host=self._db_config.host,
232
+ port=self._db_config.port,
233
+ dbname=self._db_config.database,
234
+ user=self._db_config.username,
235
+ password=self._db_config.password,
236
+ connect_timeout=int(self._db_config.connect_timeout),
237
+ )
238
+ self._db_conn.autocommit = True
239
+ return True
240
+ except Exception as exc: # pragma: no cover - network/db errors
241
+ log.error("Failed to connect to Postgres: %s", exc)
242
+ self._db_conn = None
243
+ return False
244
+
245
+
246
+ def compute_cost(limit: Optional[CostLimitConfig], usage_tokens: Optional[int], cost_per_unit: float) -> float:
247
+ if limit is None or usage_tokens is None:
248
+ return 0.0
249
+ return (usage_tokens / 1000.0) * cost_per_unit
250
+
251
+
252
+ __all__ = ["AccountingRecorder", "AccountingRecord", "compute_cost"]
@@ -0,0 +1 @@
1
+ __all__ = []
gateway/api/admin.py ADDED
@@ -0,0 +1,113 @@
1
+ """
2
+ Admin and stats endpoints.
3
+
4
+ Those endpoints are usually exposed only to localhost (admin). The statistics
5
+ endpoints can be exposed to any host by editing in the configuration file. The
6
+ admin endpoints allow termination of the daemon as well as the reloading of the
7
+ configuration (as SIGHUP and SIGTERM). The statistic endpoints expose current day
8
+ and live statistics of the router service
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime
13
+ from typing import Any, Dict
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Query, Request
16
+ from fastapi.responses import JSONResponse
17
+
18
+ from ..ipacl import is_allowed
19
+ from ..runtime import GatewayRuntime
20
+
21
+ router = APIRouter()
22
+
23
+
24
+ async def get_runtime(request: Request) -> GatewayRuntime:
25
+ runtime = getattr(request.app.state, "runtime", None)
26
+ if runtime is None:
27
+ raise HTTPException(status_code=503, detail="Gateway not ready")
28
+ return runtime
29
+
30
+
31
+ def _require_local_access(request: Request, runtime: GatewayRuntime) -> None:
32
+ """
33
+ Require local access enforces that our endpoint is one of the loopback
34
+ adresses or the unix domain socket.
35
+ """
36
+ client = request.client
37
+ if client is None:
38
+ raise HTTPException(status_code=403, detail="Forbidden")
39
+ host = client.host
40
+ if host in {"127.0.0.1", "::1"}:
41
+ return
42
+ allowlist = runtime.config_bundle.daemon.admin.stats_networks
43
+ if not allowlist:
44
+ raise HTTPException(status_code=403, detail="Forbidden")
45
+ if not is_allowed(host, allowlist):
46
+ raise HTTPException(status_code=403, detail="Forbidden")
47
+
48
+
49
+ @router.get("/stats/live")
50
+ async def stats_live(request: Request, runtime: GatewayRuntime = Depends(get_runtime)):
51
+ """
52
+ Live statistics (accessible from local host or allowlist) provide really _current_
53
+ in flight or in-queue requests.
54
+ """
55
+ _require_local_access(request, runtime)
56
+ scheduler_stats = runtime.scheduler_stats()
57
+ accounting = await runtime.accounting_snapshot()
58
+ payload = {
59
+ "per_backend": scheduler_stats.get("per_backend", {}),
60
+ "per_group": scheduler_stats.get("per_group", {}),
61
+ "per_app": accounting,
62
+ }
63
+ return JSONResponse(payload)
64
+
65
+
66
+ @router.get("/stats/usage")
67
+ async def stats_usage(
68
+ request: Request,
69
+ runtime: GatewayRuntime = Depends(get_runtime),
70
+ app_id: str | None = Query(default=None),
71
+ since: str | None = Query(default=None),
72
+ ):
73
+ """
74
+ The usage endpoint provides JSON data about the current daily usage (from current day).
75
+ It resets every day and gets populated on server restart.
76
+ """
77
+ _require_local_access(request, runtime)
78
+ accounting = await runtime.accounting_snapshot()
79
+ if app_id is not None:
80
+ accounting = {app_id: accounting.get(app_id, {"total_cost": 0.0, "request_count": 0})}
81
+ payload = {
82
+ "since": since or datetime.utcnow().isoformat() + "Z",
83
+ "apps": accounting,
84
+ }
85
+ return JSONResponse(payload)
86
+
87
+
88
+ @router.post("/admin/reload")
89
+ async def admin_reload(request: Request, runtime: GatewayRuntime = Depends(get_runtime)):
90
+ """
91
+ Reloads the server - this re-reads configuration files (!)
92
+ This is equal to SIGHUP.
93
+ """
94
+ _require_local_access(request, runtime)
95
+ await runtime.reload()
96
+ return JSONResponse({"status": "reloaded"})
97
+
98
+
99
+ @router.post("/admin/shutdown")
100
+ async def admin_shutdown(request: Request, runtime: GatewayRuntime = Depends(get_runtime)):
101
+ """
102
+ Terminate our server like SIGTERM
103
+ """
104
+ _require_local_access(request, runtime)
105
+ server = getattr(request.app.state, "server", None)
106
+ if server is None:
107
+ raise HTTPException(status_code=503, detail="Shutdown controller unavailable")
108
+ if hasattr(server, "should_exit"):
109
+ server.should_exit = True
110
+ return JSONResponse({"status": "shutting_down"})
111
+
112
+
113
+ __all__ = ["router"]