api-service-handler 0.1.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.
- api_service_handler/__init__.py +77 -0
- api_service_handler/cli.py +341 -0
- api_service_handler/client.py +868 -0
- api_service_handler/config.py +177 -0
- api_service_handler/encryption.py +238 -0
- api_service_handler/enums.py +217 -0
- api_service_handler/exceptions.py +184 -0
- api_service_handler/models.py +301 -0
- api_service_handler/py.typed +0 -0
- api_service_handler/rate_limiter.py +187 -0
- api_service_handler/rotation.py +163 -0
- api_service_handler/storage/__init__.py +7 -0
- api_service_handler/storage/base.py +243 -0
- api_service_handler/storage/memory.py +229 -0
- api_service_handler/storage/mongodb.py +432 -0
- api_service_handler/storage/postgresql.py +429 -0
- api_service_handler/storage/sqlite.py +511 -0
- api_service_handler/usage_tracker.py +219 -0
- api_service_handler/utils.py +322 -0
- api_service_handler-0.1.6.dist-info/METADATA +282 -0
- api_service_handler-0.1.6.dist-info/RECORD +23 -0
- api_service_handler-0.1.6.dist-info/WHEEL +4 -0
- api_service_handler-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""SQLite storage backend using aiosqlite."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import date, datetime, timezone
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ..enums import Provider, KeyStatus
|
|
10
|
+
from ..exceptions import KeyNotFoundError, DuplicateKeyError, StorageConnectionError
|
|
11
|
+
from ..models import APIKey, KeyFilter, KeyUpdateRequest, BulkOperationResult
|
|
12
|
+
from .base import StorageBackend
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _try_import_aiosqlite():
|
|
16
|
+
"""Lazy import aiosqlite with helpful error."""
|
|
17
|
+
try:
|
|
18
|
+
import aiosqlite
|
|
19
|
+
return aiosqlite
|
|
20
|
+
except ImportError:
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"SQLite backend requires 'aiosqlite'. "
|
|
23
|
+
"Install with: pip install api-service-handler[sqlite]"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SQLiteStorageBackend(StorageBackend):
|
|
28
|
+
"""SQLite storage backend using aiosqlite.
|
|
29
|
+
|
|
30
|
+
Good default for single-service deployments and development.
|
|
31
|
+
Install with: pip install api-service-handler[sqlite]
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, connection_string: str) -> None:
|
|
35
|
+
"""Initialize SQLite backend.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
connection_string: Path to SQLite DB file, e.g., 'sqlite:///keys.db' or '/path/to/keys.db'.
|
|
39
|
+
"""
|
|
40
|
+
self._aiosqlite = _try_import_aiosqlite()
|
|
41
|
+
# Strip sqlite:/// prefix if present
|
|
42
|
+
if connection_string.startswith("sqlite:///"):
|
|
43
|
+
self._db_path = connection_string[len("sqlite:///"):]
|
|
44
|
+
elif connection_string.startswith("sqlite://"):
|
|
45
|
+
self._db_path = connection_string[len("sqlite://"):]
|
|
46
|
+
else:
|
|
47
|
+
self._db_path = connection_string
|
|
48
|
+
self._db = None
|
|
49
|
+
self._initialized = False
|
|
50
|
+
|
|
51
|
+
async def initialize(self) -> None:
|
|
52
|
+
"""Create the SQLite database and tables."""
|
|
53
|
+
try:
|
|
54
|
+
self._db = await self._aiosqlite.connect(self._db_path)
|
|
55
|
+
await self._db.execute("PRAGMA journal_mode=WAL")
|
|
56
|
+
await self._db.execute("PRAGMA foreign_keys=ON")
|
|
57
|
+
|
|
58
|
+
await self._db.execute("""
|
|
59
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
60
|
+
id TEXT PRIMARY KEY,
|
|
61
|
+
provider TEXT NOT NULL,
|
|
62
|
+
key_value TEXT NOT NULL,
|
|
63
|
+
alias TEXT,
|
|
64
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
65
|
+
environment TEXT NOT NULL DEFAULT 'production',
|
|
66
|
+
daily_limit INTEGER,
|
|
67
|
+
monthly_limit INTEGER,
|
|
68
|
+
daily_usage_count INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
monthly_usage_count INTEGER NOT NULL DEFAULT 0,
|
|
70
|
+
total_usage_count INTEGER NOT NULL DEFAULT 0,
|
|
71
|
+
concurrent_usage INTEGER NOT NULL DEFAULT 0,
|
|
72
|
+
max_concurrent INTEGER,
|
|
73
|
+
weight INTEGER NOT NULL DEFAULT 1,
|
|
74
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
76
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
77
|
+
last_used_at TEXT,
|
|
78
|
+
last_reset_daily TEXT NOT NULL,
|
|
79
|
+
last_reset_monthly TEXT NOT NULL,
|
|
80
|
+
expires_at TEXT,
|
|
81
|
+
created_at TEXT NOT NULL,
|
|
82
|
+
updated_at TEXT NOT NULL
|
|
83
|
+
)
|
|
84
|
+
""")
|
|
85
|
+
|
|
86
|
+
await self._db.execute(
|
|
87
|
+
"CREATE INDEX IF NOT EXISTS idx_api_keys_provider ON api_keys(provider)"
|
|
88
|
+
)
|
|
89
|
+
await self._db.execute(
|
|
90
|
+
"CREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(status)"
|
|
91
|
+
)
|
|
92
|
+
await self._db.execute(
|
|
93
|
+
"CREATE INDEX IF NOT EXISTS idx_api_keys_provider_status ON api_keys(provider, status)"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
await self._db.commit()
|
|
97
|
+
self._initialized = True
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
raise StorageConnectionError(backend="sqlite", details=str(e))
|
|
101
|
+
|
|
102
|
+
async def close(self) -> None:
|
|
103
|
+
"""Close the SQLite connection."""
|
|
104
|
+
if self._db:
|
|
105
|
+
await self._db.close()
|
|
106
|
+
self._db = None
|
|
107
|
+
self._initialized = False
|
|
108
|
+
|
|
109
|
+
def _row_to_key(self, row: dict) -> APIKey:
|
|
110
|
+
"""Convert a database row to an APIKey model."""
|
|
111
|
+
data = dict(row)
|
|
112
|
+
# Parse JSON fields
|
|
113
|
+
data["metadata"] = json.loads(data.get("metadata", "{}"))
|
|
114
|
+
data["tags"] = json.loads(data.get("tags", "[]"))
|
|
115
|
+
|
|
116
|
+
# Parse datetime fields
|
|
117
|
+
for dt_field in ("last_used_at", "expires_at", "created_at", "updated_at"):
|
|
118
|
+
if data.get(dt_field):
|
|
119
|
+
data[dt_field] = datetime.fromisoformat(data[dt_field])
|
|
120
|
+
elif dt_field in ("created_at", "updated_at"):
|
|
121
|
+
data[dt_field] = datetime.now(timezone.utc)
|
|
122
|
+
|
|
123
|
+
# Parse date fields
|
|
124
|
+
for d_field in ("last_reset_daily", "last_reset_monthly"):
|
|
125
|
+
if data.get(d_field):
|
|
126
|
+
data[d_field] = date.fromisoformat(data[d_field])
|
|
127
|
+
else:
|
|
128
|
+
data[d_field] = date.today()
|
|
129
|
+
|
|
130
|
+
return APIKey(**data)
|
|
131
|
+
|
|
132
|
+
def _key_to_params(self, key: APIKey) -> dict:
|
|
133
|
+
"""Convert an APIKey model to SQLite insert parameters."""
|
|
134
|
+
return {
|
|
135
|
+
"id": key.id,
|
|
136
|
+
"provider": key.provider.value if isinstance(key.provider, Provider) else key.provider,
|
|
137
|
+
"key_value": key.key_value,
|
|
138
|
+
"alias": key.alias,
|
|
139
|
+
"status": key.status.value if isinstance(key.status, KeyStatus) else key.status,
|
|
140
|
+
"environment": key.environment.value if hasattr(key.environment, "value") else key.environment,
|
|
141
|
+
"daily_limit": key.daily_limit,
|
|
142
|
+
"monthly_limit": key.monthly_limit,
|
|
143
|
+
"daily_usage_count": key.daily_usage_count,
|
|
144
|
+
"monthly_usage_count": key.monthly_usage_count,
|
|
145
|
+
"total_usage_count": key.total_usage_count,
|
|
146
|
+
"concurrent_usage": key.concurrent_usage,
|
|
147
|
+
"max_concurrent": key.max_concurrent,
|
|
148
|
+
"weight": key.weight,
|
|
149
|
+
"priority": key.priority,
|
|
150
|
+
"metadata": json.dumps(key.metadata),
|
|
151
|
+
"tags": json.dumps(key.tags),
|
|
152
|
+
"last_used_at": key.last_used_at.isoformat() if key.last_used_at else None,
|
|
153
|
+
"last_reset_daily": key.last_reset_daily.isoformat(),
|
|
154
|
+
"last_reset_monthly": key.last_reset_monthly.isoformat(),
|
|
155
|
+
"expires_at": key.expires_at.isoformat() if key.expires_at else None,
|
|
156
|
+
"created_at": key.created_at.isoformat(),
|
|
157
|
+
"updated_at": key.updated_at.isoformat(),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# ── CRUD ───────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async def add_key(self, key: APIKey) -> APIKey:
|
|
163
|
+
"""Insert a new key into SQLite."""
|
|
164
|
+
# Check for duplicates
|
|
165
|
+
provider_val = key.provider.value if isinstance(key.provider, Provider) else key.provider
|
|
166
|
+
async with self._db.execute(
|
|
167
|
+
"SELECT id FROM api_keys WHERE key_value = ? AND provider = ? AND status != 'revoked'",
|
|
168
|
+
(key.key_value, provider_val),
|
|
169
|
+
) as cursor:
|
|
170
|
+
if await cursor.fetchone():
|
|
171
|
+
raise DuplicateKeyError(provider=provider_val, key_value=key.key_value)
|
|
172
|
+
|
|
173
|
+
params = self._key_to_params(key)
|
|
174
|
+
columns = ", ".join(params.keys())
|
|
175
|
+
placeholders = ", ".join(f":{k}" for k in params.keys())
|
|
176
|
+
|
|
177
|
+
await self._db.execute(
|
|
178
|
+
f"INSERT INTO api_keys ({columns}) VALUES ({placeholders})",
|
|
179
|
+
params,
|
|
180
|
+
)
|
|
181
|
+
await self._db.commit()
|
|
182
|
+
return key
|
|
183
|
+
|
|
184
|
+
async def get_key(self, key_id: str) -> APIKey:
|
|
185
|
+
"""Get a key by ID."""
|
|
186
|
+
self._db.row_factory = self._aiosqlite.Row
|
|
187
|
+
async with self._db.execute(
|
|
188
|
+
"SELECT * FROM api_keys WHERE id = ?", (key_id,)
|
|
189
|
+
) as cursor:
|
|
190
|
+
row = await cursor.fetchone()
|
|
191
|
+
if not row:
|
|
192
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
193
|
+
return self._row_to_key(dict(row))
|
|
194
|
+
|
|
195
|
+
async def get_keys_by_provider(self, provider: Provider) -> list[APIKey]:
|
|
196
|
+
"""Get all keys for a provider."""
|
|
197
|
+
provider_val = provider.value if isinstance(provider, Provider) else provider
|
|
198
|
+
self._db.row_factory = self._aiosqlite.Row
|
|
199
|
+
async with self._db.execute(
|
|
200
|
+
"SELECT * FROM api_keys WHERE provider = ?", (provider_val,)
|
|
201
|
+
) as cursor:
|
|
202
|
+
rows = await cursor.fetchall()
|
|
203
|
+
return [self._row_to_key(dict(row)) for row in rows]
|
|
204
|
+
|
|
205
|
+
async def get_all_keys(self, key_filter: Optional[KeyFilter] = None) -> list[APIKey]:
|
|
206
|
+
"""Get all keys with optional filtering."""
|
|
207
|
+
self._db.row_factory = self._aiosqlite.Row
|
|
208
|
+
|
|
209
|
+
query = "SELECT * FROM api_keys"
|
|
210
|
+
params: list = []
|
|
211
|
+
conditions: list[str] = []
|
|
212
|
+
|
|
213
|
+
if key_filter:
|
|
214
|
+
if key_filter.provider is not None:
|
|
215
|
+
provider_val = (
|
|
216
|
+
key_filter.provider.value
|
|
217
|
+
if isinstance(key_filter.provider, Provider)
|
|
218
|
+
else key_filter.provider
|
|
219
|
+
)
|
|
220
|
+
conditions.append("provider = ?")
|
|
221
|
+
params.append(provider_val)
|
|
222
|
+
if key_filter.status is not None:
|
|
223
|
+
status_val = (
|
|
224
|
+
key_filter.status.value
|
|
225
|
+
if isinstance(key_filter.status, KeyStatus)
|
|
226
|
+
else key_filter.status
|
|
227
|
+
)
|
|
228
|
+
conditions.append("status = ?")
|
|
229
|
+
params.append(status_val)
|
|
230
|
+
if key_filter.environment is not None:
|
|
231
|
+
env_val = (
|
|
232
|
+
key_filter.environment.value
|
|
233
|
+
if hasattr(key_filter.environment, "value")
|
|
234
|
+
else key_filter.environment
|
|
235
|
+
)
|
|
236
|
+
conditions.append("environment = ?")
|
|
237
|
+
params.append(env_val)
|
|
238
|
+
if key_filter.alias_contains:
|
|
239
|
+
conditions.append("alias LIKE ?")
|
|
240
|
+
params.append(f"%{key_filter.alias_contains}%")
|
|
241
|
+
|
|
242
|
+
if conditions:
|
|
243
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
244
|
+
|
|
245
|
+
query += " ORDER BY priority ASC, created_at ASC"
|
|
246
|
+
|
|
247
|
+
async with self._db.execute(query, params) as cursor:
|
|
248
|
+
rows = await cursor.fetchall()
|
|
249
|
+
keys = [self._row_to_key(dict(row)) for row in rows]
|
|
250
|
+
|
|
251
|
+
# Apply in-memory filters that can't be done in SQL easily
|
|
252
|
+
if key_filter:
|
|
253
|
+
if key_filter.tags:
|
|
254
|
+
keys = [
|
|
255
|
+
k for k in keys
|
|
256
|
+
if any(tag in k.tags for tag in key_filter.tags)
|
|
257
|
+
]
|
|
258
|
+
if key_filter.metadata_filter:
|
|
259
|
+
keys = [
|
|
260
|
+
k for k in keys
|
|
261
|
+
if all(
|
|
262
|
+
k.metadata.get(mk) == mv
|
|
263
|
+
for mk, mv in key_filter.metadata_filter.items()
|
|
264
|
+
)
|
|
265
|
+
]
|
|
266
|
+
if key_filter.has_capacity is not None:
|
|
267
|
+
keys = [k for k in keys if k.has_capacity == key_filter.has_capacity]
|
|
268
|
+
|
|
269
|
+
return keys
|
|
270
|
+
|
|
271
|
+
async def update_key(self, key_id: str, updates: KeyUpdateRequest) -> APIKey:
|
|
272
|
+
"""Update a key's fields."""
|
|
273
|
+
# Verify key exists
|
|
274
|
+
await self.get_key(key_id)
|
|
275
|
+
|
|
276
|
+
update_data = updates.model_dump(exclude_unset=True)
|
|
277
|
+
if not update_data:
|
|
278
|
+
return await self.get_key(key_id)
|
|
279
|
+
|
|
280
|
+
update_data["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
281
|
+
|
|
282
|
+
# Serialize complex fields
|
|
283
|
+
if "metadata" in update_data:
|
|
284
|
+
update_data["metadata"] = json.dumps(update_data["metadata"])
|
|
285
|
+
if "tags" in update_data:
|
|
286
|
+
update_data["tags"] = json.dumps(update_data["tags"])
|
|
287
|
+
if "expires_at" in update_data and update_data["expires_at"]:
|
|
288
|
+
update_data["expires_at"] = update_data["expires_at"].isoformat()
|
|
289
|
+
if "status" in update_data:
|
|
290
|
+
val = update_data["status"]
|
|
291
|
+
update_data["status"] = val.value if hasattr(val, "value") else val
|
|
292
|
+
if "environment" in update_data:
|
|
293
|
+
val = update_data["environment"]
|
|
294
|
+
update_data["environment"] = val.value if hasattr(val, "value") else val
|
|
295
|
+
|
|
296
|
+
set_clause = ", ".join(f"{k} = :{k}" for k in update_data.keys())
|
|
297
|
+
update_data["_id"] = key_id
|
|
298
|
+
|
|
299
|
+
await self._db.execute(
|
|
300
|
+
f"UPDATE api_keys SET {set_clause} WHERE id = :_id",
|
|
301
|
+
update_data,
|
|
302
|
+
)
|
|
303
|
+
await self._db.commit()
|
|
304
|
+
return await self.get_key(key_id)
|
|
305
|
+
|
|
306
|
+
async def delete_key(self, key_id: str, soft: bool = True) -> bool:
|
|
307
|
+
"""Delete or revoke a key."""
|
|
308
|
+
await self.get_key(key_id) # Verify exists
|
|
309
|
+
|
|
310
|
+
if soft:
|
|
311
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
312
|
+
await self._db.execute(
|
|
313
|
+
"UPDATE api_keys SET status = 'revoked', updated_at = ? WHERE id = ?",
|
|
314
|
+
(now, key_id),
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
await self._db.execute("DELETE FROM api_keys WHERE id = ?", (key_id,))
|
|
318
|
+
|
|
319
|
+
await self._db.commit()
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
# ── Usage Tracking ─────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
async def increment_usage(
|
|
325
|
+
self,
|
|
326
|
+
key_id: str,
|
|
327
|
+
daily: int = 1,
|
|
328
|
+
monthly: int = 1,
|
|
329
|
+
total: int = 1,
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Atomically increment usage counters."""
|
|
332
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
333
|
+
await self._db.execute(
|
|
334
|
+
"""
|
|
335
|
+
UPDATE api_keys
|
|
336
|
+
SET daily_usage_count = daily_usage_count + ?,
|
|
337
|
+
monthly_usage_count = monthly_usage_count + ?,
|
|
338
|
+
total_usage_count = total_usage_count + ?,
|
|
339
|
+
last_used_at = ?,
|
|
340
|
+
updated_at = ?
|
|
341
|
+
WHERE id = ?
|
|
342
|
+
""",
|
|
343
|
+
(daily, monthly, total, now, now, key_id),
|
|
344
|
+
)
|
|
345
|
+
await self._db.commit()
|
|
346
|
+
|
|
347
|
+
async def update_concurrent_usage(self, key_id: str, delta: int) -> int:
|
|
348
|
+
"""Adjust concurrent usage."""
|
|
349
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
350
|
+
await self._db.execute(
|
|
351
|
+
"""
|
|
352
|
+
UPDATE api_keys
|
|
353
|
+
SET concurrent_usage = MAX(0, concurrent_usage + ?),
|
|
354
|
+
updated_at = ?
|
|
355
|
+
WHERE id = ?
|
|
356
|
+
""",
|
|
357
|
+
(delta, now, key_id),
|
|
358
|
+
)
|
|
359
|
+
await self._db.commit()
|
|
360
|
+
|
|
361
|
+
self._db.row_factory = self._aiosqlite.Row
|
|
362
|
+
async with self._db.execute(
|
|
363
|
+
"SELECT concurrent_usage FROM api_keys WHERE id = ?", (key_id,)
|
|
364
|
+
) as cursor:
|
|
365
|
+
row = await cursor.fetchone()
|
|
366
|
+
if not row:
|
|
367
|
+
raise KeyNotFoundError(key_id=key_id)
|
|
368
|
+
return row["concurrent_usage"]
|
|
369
|
+
|
|
370
|
+
async def reset_daily_counts(self, before_date: date) -> int:
|
|
371
|
+
"""Reset daily counts."""
|
|
372
|
+
before_str = before_date.isoformat()
|
|
373
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
374
|
+
|
|
375
|
+
cursor = await self._db.execute(
|
|
376
|
+
"""
|
|
377
|
+
UPDATE api_keys
|
|
378
|
+
SET daily_usage_count = 0,
|
|
379
|
+
last_reset_daily = ?,
|
|
380
|
+
updated_at = ?
|
|
381
|
+
WHERE last_reset_daily < ?
|
|
382
|
+
""",
|
|
383
|
+
(before_str, now, before_str),
|
|
384
|
+
)
|
|
385
|
+
# Recover rate-limited keys if monthly is ok
|
|
386
|
+
await self._db.execute(
|
|
387
|
+
"""
|
|
388
|
+
UPDATE api_keys
|
|
389
|
+
SET status = 'active'
|
|
390
|
+
WHERE status = 'rate_limited'
|
|
391
|
+
AND (monthly_limit IS NULL OR monthly_usage_count < monthly_limit)
|
|
392
|
+
""",
|
|
393
|
+
)
|
|
394
|
+
await self._db.commit()
|
|
395
|
+
return cursor.rowcount
|
|
396
|
+
|
|
397
|
+
async def reset_monthly_counts(self, before_date: date) -> int:
|
|
398
|
+
"""Reset monthly counts."""
|
|
399
|
+
before_str = before_date.isoformat()
|
|
400
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
401
|
+
|
|
402
|
+
# Get keys that need reset (different month/year)
|
|
403
|
+
self._db.row_factory = self._aiosqlite.Row
|
|
404
|
+
async with self._db.execute(
|
|
405
|
+
"SELECT id, last_reset_monthly FROM api_keys"
|
|
406
|
+
) as cursor:
|
|
407
|
+
rows = await cursor.fetchall()
|
|
408
|
+
|
|
409
|
+
ids_to_reset = []
|
|
410
|
+
for row in rows:
|
|
411
|
+
last_reset = date.fromisoformat(row["last_reset_monthly"])
|
|
412
|
+
if last_reset.month != before_date.month or last_reset.year != before_date.year:
|
|
413
|
+
ids_to_reset.append(row["id"])
|
|
414
|
+
|
|
415
|
+
if not ids_to_reset:
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
placeholders = ",".join("?" for _ in ids_to_reset)
|
|
419
|
+
await self._db.execute(
|
|
420
|
+
f"""
|
|
421
|
+
UPDATE api_keys
|
|
422
|
+
SET monthly_usage_count = 0,
|
|
423
|
+
last_reset_monthly = ?,
|
|
424
|
+
status = CASE WHEN status = 'rate_limited' THEN 'active' ELSE status END,
|
|
425
|
+
updated_at = ?
|
|
426
|
+
WHERE id IN ({placeholders})
|
|
427
|
+
""",
|
|
428
|
+
[before_str, now] + ids_to_reset,
|
|
429
|
+
)
|
|
430
|
+
await self._db.commit()
|
|
431
|
+
return len(ids_to_reset)
|
|
432
|
+
|
|
433
|
+
async def update_last_used(self, key_id: str) -> None:
|
|
434
|
+
"""Update last_used_at."""
|
|
435
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
436
|
+
await self._db.execute(
|
|
437
|
+
"UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?",
|
|
438
|
+
(now, now, key_id),
|
|
439
|
+
)
|
|
440
|
+
await self._db.commit()
|
|
441
|
+
|
|
442
|
+
# ── Bulk Operations ────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
async def bulk_add_keys(self, keys: list[APIKey]) -> BulkOperationResult:
|
|
445
|
+
"""Insert multiple keys."""
|
|
446
|
+
result = BulkOperationResult(total=len(keys))
|
|
447
|
+
|
|
448
|
+
for key in keys:
|
|
449
|
+
try:
|
|
450
|
+
await self.add_key(key)
|
|
451
|
+
result.successful += 1
|
|
452
|
+
result.created_ids.append(key.id)
|
|
453
|
+
except Exception as e:
|
|
454
|
+
result.failed += 1
|
|
455
|
+
result.errors.append(f"Key {key.alias or key.id}: {e}")
|
|
456
|
+
|
|
457
|
+
return result
|
|
458
|
+
|
|
459
|
+
async def bulk_delete_keys(self, key_ids: list[str], soft: bool = True) -> BulkOperationResult:
|
|
460
|
+
"""Delete multiple keys."""
|
|
461
|
+
result = BulkOperationResult(total=len(key_ids))
|
|
462
|
+
|
|
463
|
+
for key_id in key_ids:
|
|
464
|
+
try:
|
|
465
|
+
await self.delete_key(key_id, soft=soft)
|
|
466
|
+
result.successful += 1
|
|
467
|
+
except Exception as e:
|
|
468
|
+
result.failed += 1
|
|
469
|
+
result.errors.append(f"Key {key_id}: {e}")
|
|
470
|
+
|
|
471
|
+
return result
|
|
472
|
+
|
|
473
|
+
# ── Health ─────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
async def health_check(self) -> bool:
|
|
476
|
+
"""Check SQLite is accessible."""
|
|
477
|
+
if not self._db or not self._initialized:
|
|
478
|
+
return False
|
|
479
|
+
try:
|
|
480
|
+
async with self._db.execute("SELECT 1") as cursor:
|
|
481
|
+
await cursor.fetchone()
|
|
482
|
+
return True
|
|
483
|
+
except Exception:
|
|
484
|
+
return False
|
|
485
|
+
|
|
486
|
+
async def count_keys(
|
|
487
|
+
self,
|
|
488
|
+
provider: Optional[Provider] = None,
|
|
489
|
+
status: Optional[KeyStatus] = None,
|
|
490
|
+
) -> int:
|
|
491
|
+
"""Count keys matching criteria."""
|
|
492
|
+
query = "SELECT COUNT(*) as cnt FROM api_keys"
|
|
493
|
+
params: list = []
|
|
494
|
+
conditions: list[str] = []
|
|
495
|
+
|
|
496
|
+
if provider is not None:
|
|
497
|
+
provider_val = provider.value if isinstance(provider, Provider) else provider
|
|
498
|
+
conditions.append("provider = ?")
|
|
499
|
+
params.append(provider_val)
|
|
500
|
+
if status is not None:
|
|
501
|
+
status_val = status.value if isinstance(status, KeyStatus) else status
|
|
502
|
+
conditions.append("status = ?")
|
|
503
|
+
params.append(status_val)
|
|
504
|
+
|
|
505
|
+
if conditions:
|
|
506
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
507
|
+
|
|
508
|
+
self._db.row_factory = self._aiosqlite.Row
|
|
509
|
+
async with self._db.execute(query, params) as cursor:
|
|
510
|
+
row = await cursor.fetchone()
|
|
511
|
+
return row["cnt"] if row else 0
|