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 +3 -0
- gateway/accounting.py +252 -0
- gateway/api/__init__.py +1 -0
- gateway/api/admin.py +113 -0
- gateway/api/v1.py +359 -0
- gateway/app.py +60 -0
- gateway/auth.py +55 -0
- gateway/backends/__init__.py +27 -0
- gateway/backends/anthropic.py +66 -0
- gateway/backends/base.py +69 -0
- gateway/backends/ollama.py +558 -0
- gateway/backends/openai.py +65 -0
- gateway/cli.py +279 -0
- gateway/config.py +571 -0
- gateway/ipacl.py +25 -0
- gateway/log.py +51 -0
- gateway/main.py +8 -0
- gateway/middleware/__init__.py +0 -0
- gateway/middleware/trace.py +48 -0
- gateway/policies.py +33 -0
- gateway/routing.py +146 -0
- gateway/runtime.py +223 -0
- gateway/scheduling.py +131 -0
- gateway/signals.py +34 -0
- gateway/sse.py +28 -0
- gateway/trace.py +186 -0
- mini_apigw-0.0.6.dist-info/METADATA +313 -0
- mini_apigw-0.0.6.dist-info/RECORD +32 -0
- mini_apigw-0.0.6.dist-info/WHEEL +5 -0
- mini_apigw-0.0.6.dist-info/entry_points.txt +2 -0
- mini_apigw-0.0.6.dist-info/licenses/LICENSE.md +24 -0
- mini_apigw-0.0.6.dist-info/top_level.txt +1 -0
gateway/__init__.py
ADDED
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"]
|
gateway/api/__init__.py
ADDED
|
@@ -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"]
|