api-key-manager 2.1.0__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.
Files changed (73) hide show
  1. api_key_manager-2.1.0.dist-info/METADATA +709 -0
  2. api_key_manager-2.1.0.dist-info/RECORD +73 -0
  3. api_key_manager-2.1.0.dist-info/WHEEL +5 -0
  4. api_key_manager-2.1.0.dist-info/entry_points.txt +2 -0
  5. api_key_manager-2.1.0.dist-info/top_level.txt +1 -0
  6. key_manager/__init__.py +16 -0
  7. key_manager/__main__.py +5 -0
  8. key_manager/api_models.py +358 -0
  9. key_manager/checker.py +51 -0
  10. key_manager/cli.py +270 -0
  11. key_manager/config.py +61 -0
  12. key_manager/core.py +205 -0
  13. key_manager/detector.py +335 -0
  14. key_manager/errors.py +179 -0
  15. key_manager/i18n.py +142 -0
  16. key_manager/logger.py +207 -0
  17. key_manager/model_capabilities.py +412 -0
  18. key_manager/parser.py +153 -0
  19. key_manager/providers/__init__.py +283 -0
  20. key_manager/providers/ai302.py +109 -0
  21. key_manager/providers/anthropic.py +109 -0
  22. key_manager/providers/baichuan.py +97 -0
  23. key_manager/providers/base.py +312 -0
  24. key_manager/providers/cerebras.py +109 -0
  25. key_manager/providers/cohere.py +90 -0
  26. key_manager/providers/cstcloud.py +122 -0
  27. key_manager/providers/dashscope.py +120 -0
  28. key_manager/providers/dashscope_coding.py +122 -0
  29. key_manager/providers/deepseek.py +166 -0
  30. key_manager/providers/dmxapi.py +109 -0
  31. key_manager/providers/doubao.py +109 -0
  32. key_manager/providers/fireworks.py +109 -0
  33. key_manager/providers/google.py +99 -0
  34. key_manager/providers/grok.py +109 -0
  35. key_manager/providers/groq.py +109 -0
  36. key_manager/providers/huggingface.py +54 -0
  37. key_manager/providers/hyperbolic.py +109 -0
  38. key_manager/providers/infini.py +135 -0
  39. key_manager/providers/infini_coding.py +124 -0
  40. key_manager/providers/kimi.py +121 -0
  41. key_manager/providers/kimi_coding.py +124 -0
  42. key_manager/providers/longcat.py +123 -0
  43. key_manager/providers/mimo.py +109 -0
  44. key_manager/providers/mimo_plan.py +140 -0
  45. key_manager/providers/minimax.py +97 -0
  46. key_manager/providers/minimax_plan.py +122 -0
  47. key_manager/providers/mistral.py +109 -0
  48. key_manager/providers/models_registry.py +2901 -0
  49. key_manager/providers/modelscope.py +134 -0
  50. key_manager/providers/nvidia.py +109 -0
  51. key_manager/providers/ocoolai.py +109 -0
  52. key_manager/providers/openai.py +140 -0
  53. key_manager/providers/openrouter.py +119 -0
  54. key_manager/providers/perplexity.py +109 -0
  55. key_manager/providers/poe.py +109 -0
  56. key_manager/providers/ppio.py +109 -0
  57. key_manager/providers/replicate.py +54 -0
  58. key_manager/providers/siliconflow.py +121 -0
  59. key_manager/providers/stepfun.py +132 -0
  60. key_manager/providers/tencent_hunyuan.py +122 -0
  61. key_manager/providers/together.py +134 -0
  62. key_manager/providers/yi.py +97 -0
  63. key_manager/providers/zai.py +109 -0
  64. key_manager/providers/zhipu.py +127 -0
  65. key_manager/providers/zhipu_coding.py +124 -0
  66. key_manager/proxy.py +70 -0
  67. key_manager/ssrf.py +68 -0
  68. key_manager/storage.py +134 -0
  69. key_manager/tester.py +137 -0
  70. key_manager/url_override.py +5 -0
  71. key_manager/validator.py +185 -0
  72. key_manager/web.py +1512 -0
  73. key_manager/webhook.py +257 -0
key_manager/web.py ADDED
@@ -0,0 +1,1512 @@
1
+ """FastAPI application for API Key Manager.
2
+
3
+ Serves the web UI and REST API for managing API keys across 37+ providers.
4
+ """
5
+
6
+ import time
7
+ import asyncio
8
+ import os
9
+ import json
10
+ import threading
11
+ from datetime import datetime, timedelta
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ import httpx
16
+ import pydantic
17
+ from fastapi import FastAPI, Request, Query, UploadFile, File, Form, Body
18
+ from fastapi.responses import JSONResponse, StreamingResponse, HTMLResponse
19
+ from fastapi.exceptions import RequestValidationError
20
+ from fastapi.templating import Jinja2Templates
21
+
22
+ from key_manager.config import load_config
23
+ from key_manager.parser import import_keys, mask_key, validate_import_path
24
+ from key_manager.validator import validate_keys
25
+ from key_manager.checker import run_check
26
+ from key_manager.tester import run_test
27
+ from key_manager.proxy import get_proxy, detect_system_proxy
28
+ from key_manager.storage import KeyStore
29
+ from key_manager.providers import (
30
+ PROVIDERS,
31
+ get_display_name,
32
+ DISPLAY_NAMES,
33
+ PROVIDER_WEBSITES,
34
+ KEY_PREFIX_MAP,
35
+ )
36
+ from key_manager.providers.base import simplify_error
37
+ from key_manager.detector import detect_by_prefix, detect_provider
38
+ from key_manager.logger import project_logger
39
+ from key_manager.i18n import get_lang_from_header, set_lang, t
40
+ from key_manager.webhook import webhook_manager, WebhookEvent
41
+ from key_manager.errors import (
42
+ ErrorCode,
43
+ ErrorResponse,
44
+ KeyManagerError,
45
+ ValidationError,
46
+ StorageError,
47
+ ProviderError,
48
+ SystemError,
49
+ )
50
+
51
+ from key_manager.api_models import (
52
+ KeyInfo,
53
+ KeyListResponse,
54
+ KeyExportItem,
55
+ KeyExportResponse,
56
+ ImportRequest,
57
+ ImportResponse,
58
+ CheckSingleRequest,
59
+ CheckSingleResponse,
60
+ CheckBatchItem,
61
+ CheckBatchRequest,
62
+ CheckBatchResult,
63
+ CheckBatchSummary,
64
+ CheckBatchResponse,
65
+ TestSingleRequest,
66
+ TestSingleResponse,
67
+ BalanceRequest,
68
+ BalanceResponse,
69
+ ModelInfo,
70
+ ModelsResponse,
71
+ ProviderInfo as ProviderInfoModel,
72
+ ProvidersResponse,
73
+ ProviderDetail,
74
+ ProviderDetailResponse,
75
+ StatsProviderEntry,
76
+ StatsResponse,
77
+ StatsChartProviderEntry,
78
+ StatsChartStatuses,
79
+ StatsChartResponse,
80
+ LogEntry,
81
+ LogsResponse,
82
+ OperationEntry,
83
+ OperationsResponse,
84
+ ProgressResponse,
85
+ )
86
+
87
+ # ── Module-level config ────────────────────────────────────────────────────────
88
+
89
+ config = load_config()
90
+
91
+ # ── Debug system ──────────────────────────────────────────────────────────────
92
+ try:
93
+ import sys
94
+ sys.path.insert(0, str(Path(__file__).parent.parent))
95
+ from webdebug import init_debug, debug_logger, FunctionTracer
96
+ DEBUG_ENABLED = True
97
+ except ImportError:
98
+ DEBUG_ENABLED = False
99
+ debug_logger = None
100
+ FunctionTracer = None
101
+ # ── FastAPI application ────────────────────────────────────────────────────────
102
+
103
+ app = FastAPI(
104
+ title="API Key Manager",
105
+ description="Manage and validate API keys for 37+ AI providers. "
106
+ "Import, check validity, test token limits and concurrency, "
107
+ "query balances, and export working keys.",
108
+ version="2.1.0",
109
+ openapi_tags=[
110
+ {"name": "Keys", "description": "Key management operations"},
111
+ {"name": "Check", "description": "Key validity checking"},
112
+ {"name": "Test", "description": "Token limit & concurrency testing"},
113
+ {"name": "Balance", "description": "Account balance queries"},
114
+ {"name": "Models", "description": "Available model queries"},
115
+ {"name": "Providers", "description": "Provider registry & details"},
116
+ {"name": "Stats", "description": "Statistics & charts"},
117
+ {"name": "Logs", "description": "Operation logs"},
118
+ {"name": "Progress", "description": "Long-running task progress"},
119
+ ],
120
+ )
121
+
122
+ # ── Initialize debug system ──────────────────────────────────────────────────
123
+
124
+ if DEBUG_ENABLED:
125
+ debug_tracer = init_debug(app)
126
+ tracer = FunctionTracer(debug_logger, category="API")
127
+ else:
128
+ debug_tracer = None
129
+ tracer = None
130
+
131
+ # ── Global progress tracker (shared-memory, thread-safe) ──────────────────────
132
+
133
+ class ProgressTracker:
134
+ """Thread-safe progress tracker for long-running operations."""
135
+
136
+ def __init__(self):
137
+ self._lock = threading.Lock()
138
+ self._active = False
139
+ self._current = 0
140
+ self._total = 0
141
+ self._status = ""
142
+ self._results: dict[str, Any] | None = None
143
+
144
+ def start(self, total: int, status: str = "loading"):
145
+ with self._lock:
146
+ self._active = True
147
+ self._current = 0
148
+ self._total = total
149
+ self._status = status
150
+ self._results = None
151
+
152
+ def update(self, current: int, total: int):
153
+ with self._lock:
154
+ self._current = current
155
+ self._total = total
156
+
157
+ def done(self, status: str = "done", results: dict[str, Any] | None = None):
158
+ with self._lock:
159
+ self._active = False
160
+ self._current = self._total
161
+ self._status = status
162
+ self._results = results
163
+
164
+ def snapshot(self) -> ProgressResponse:
165
+ with self._lock:
166
+ return ProgressResponse(
167
+ active=self._active,
168
+ current=self._current,
169
+ total=self._total,
170
+ status=self._status,
171
+ results=self._results,
172
+ )
173
+
174
+
175
+ _progress_tracker = ProgressTracker()
176
+
177
+
178
+ def _make_progress_callback():
179
+ """Return a (current, total) callable that updates the global tracker."""
180
+ def cb(current: int, total: int):
181
+ _progress_tracker.update(current, total)
182
+ return cb
183
+
184
+
185
+ # ── SSE helpers ────────────────────────────────────────────────────────────────
186
+
187
+ async def _sse_progress_event_generator(poll_interval: float = 0.5):
188
+ """SSE generator that polls the progress tracker until the task completes."""
189
+ while True:
190
+ snap = _progress_tracker.snapshot()
191
+ data = snap.model_dump_json()
192
+ yield f"data: {data}\n\n"
193
+ if not snap.active:
194
+ yield "data: [DONE]\n\n"
195
+ break
196
+ await asyncio.sleep(poll_interval)
197
+
198
+
199
+ # ── Helper: load keys store ──────────────────────────────────────────────────
200
+
201
+ def _load_keys_store(config_override: dict | None = None) -> KeyStore:
202
+ cfg = config_override or config
203
+ return KeyStore(cfg["storage"]["keys_file"], cfg)
204
+
205
+
206
+ def _load_keys_data(config_override: dict | None = None) -> dict:
207
+ cfg = config_override or config
208
+ try:
209
+ return _load_keys_store(cfg).load()
210
+ except Exception:
211
+ keys_path = Path(cfg["storage"]["keys_file"])
212
+ if not keys_path.exists():
213
+ return {"keys": {}}
214
+ with open(keys_path, "r", encoding="utf-8") as f:
215
+ return json.load(f)
216
+
217
+
218
+ def _save_keys_data(data: dict, config_override: dict | None = None):
219
+ cfg = config_override or config
220
+ _load_keys_store(cfg).save(data)
221
+
222
+
223
+ # ── Middleware: rate limit by IP ─────────────────────────────────────────────
224
+
225
+ _RATE_LIMIT_STORE: dict[str, list[float]] = {}
226
+
227
+
228
+ @app.middleware("http")
229
+ async def rate_limit_middleware(request: Request, call_next):
230
+ # Skip for static/docs and auth whitelist
231
+ if request.url.path in _AUTH_WHITELIST or request.url.path.startswith("/static"):
232
+ return await call_next(request)
233
+
234
+ rate_limit_config = config.get("rate_limit", {})
235
+ requests_per_minute = rate_limit_config.get("requests_per_minute", 60)
236
+ if not requests_per_minute:
237
+ return await call_next(request)
238
+
239
+ client_ip = request.client.host if request.client else "unknown"
240
+ now = time.time()
241
+
242
+ # Clean old entries
243
+ if client_ip in _RATE_LIMIT_STORE:
244
+ _RATE_LIMIT_STORE[client_ip] = [t for t in _RATE_LIMIT_STORE[client_ip] if now - t < 60.0]
245
+ else:
246
+ _RATE_LIMIT_STORE[client_ip] = []
247
+
248
+ if len(_RATE_LIMIT_STORE[client_ip]) >= requests_per_minute:
249
+ return JSONResponse(
250
+ status_code=429,
251
+ content={"error": {"code": "RATE_LIMITED", "message": "Too many requests", "details": {"retry_after": 1}}}
252
+ )
253
+
254
+ _RATE_LIMIT_STORE[client_ip].append(now)
255
+ return await call_next(request)
256
+
257
+ # ── Middleware: authenticate requests via Bearer token ───────────────────────
258
+
259
+ _AUTH_WHITELIST = {"/", "/docs", "/redoc", "/openapi.json"}
260
+
261
+
262
+ @app.middleware("http")
263
+ async def auth_middleware(request: Request, call_next):
264
+ api_key = config.get("auth", {}).get("api_key", "") or os.environ.get("KEY_MANAGER_API_KEY", "")
265
+ if not api_key:
266
+ # Log warning once at startup (not on every request)
267
+ if not hasattr(app, '_auth_warning_logged'):
268
+ import logging
269
+ logging.getLogger(__name__).warning(
270
+ "Authentication is disabled. Set KEY_MANAGER_API_KEY env var or "
271
+ "auth.api_key in config.yaml to secure your API."
272
+ )
273
+ app._auth_warning_logged = True
274
+ return await call_next(request)
275
+ if request.url.path in _AUTH_WHITELIST:
276
+ return await call_next(request)
277
+ auth_header = request.headers.get("authorization", "")
278
+ if auth_header == f"Bearer {api_key}":
279
+ return await call_next(request)
280
+ response = ErrorResponse.error_factory(
281
+ code=ErrorCode.AUTH_REQUIRED,
282
+ message=t(ErrorCode.AUTH_REQUIRED.value),
283
+ )
284
+ return JSONResponse(status_code=401, content=response.model_dump())
285
+
286
+ # ── Middleware: set language from Accept-Language header ──────────────────────
287
+
288
+ @app.middleware("http")
289
+ async def i18n_middleware(request: Request, call_next):
290
+ lang = get_lang_from_header(request.headers.get("accept-language"))
291
+ set_lang(lang)
292
+ response = await call_next(request)
293
+ return response
294
+
295
+
296
+ # ── Error handlers ────────────────────────────────────────────────────────────
297
+
298
+
299
+ @app.exception_handler(RequestValidationError)
300
+ async def pydantic_validation_error_handler(request: Request, exc: RequestValidationError):
301
+ """Convert Pydantic/body validation errors to 400 with structured ErrorResponse."""
302
+ errors = []
303
+ code = ErrorCode.VALIDATION_MISSING_KEY
304
+ for err in exc.errors():
305
+ loc_list = err.get("loc", [])
306
+ msg = err.get("msg", "")
307
+ loc_str = ".".join(str(x) for x in loc_list)
308
+ errors.append(f"{loc_str}: {msg}")
309
+ if "key" in loc_list:
310
+ code = ErrorCode.VALIDATION_MISSING_KEY
311
+ elif "file" in loc_list:
312
+ code = ErrorCode.VALIDATION_FILE_NOT_FOUND
313
+ elif "provider" in loc_list:
314
+ code = ErrorCode.VALIDATION_PROVIDER_UNKNOWN
315
+
316
+ response = ErrorResponse.error_factory(
317
+ code=code,
318
+ message=t(code.value),
319
+ details={"validation_errors": errors},
320
+ )
321
+ return JSONResponse(status_code=400, content=response.model_dump())
322
+
323
+
324
+ @app.exception_handler(KeyManagerError)
325
+ async def key_manager_error_handler(request: Request, exc: KeyManagerError):
326
+ status_code, body = exc.to_response()
327
+ return JSONResponse(status_code=status_code, content=body.model_dump())
328
+
329
+
330
+ @app.exception_handler(ValidationError)
331
+ async def validation_error_handler(request: Request, exc: ValidationError):
332
+ status_code, body = exc.to_response()
333
+ return JSONResponse(status_code=status_code, content=body.model_dump())
334
+
335
+
336
+ # ══════════════════════════════════════════════════════════════════════════════
337
+ # WEB UI
338
+ # ══════════════════════════════════════════════════════════════════════════════
339
+
340
+ _TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
341
+ _TEMPLATES_DIR_ALT = Path(__file__).resolve().parent / "templates"
342
+
343
+ if _TEMPLATES_DIR.exists():
344
+ templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
345
+ elif _TEMPLATES_DIR_ALT.exists():
346
+ templates = Jinja2Templates(directory=str(_TEMPLATES_DIR_ALT))
347
+ else:
348
+ templates = None
349
+
350
+
351
+ @app.get("/", include_in_schema=False)
352
+ async def web_ui(request: Request):
353
+ """Serve the web UI."""
354
+ if templates and (_TEMPLATES_DIR / "index.html").exists():
355
+ return templates.TemplateResponse("index.html", {"request": request})
356
+ if templates and (_TEMPLATES_DIR_ALT / "index.html").exists():
357
+ return templates.TemplateResponse("index.html", {"request": request})
358
+ return HTMLResponse("<html><body><h1>API Key Manager</h1><p>Web UI not found.</p></body></html>")
359
+
360
+
361
+ # ══════════════════════════════════════════════════════════════════════════════
362
+ # KEYS
363
+ # ══════════════════════════════════════════════════════════════════════════════
364
+
365
+ @app.post("/api/import", tags=["Keys"], response_model=ImportResponse)
366
+ async def api_import(body: ImportRequest):
367
+ """Import keys from a JSON file, directory, or inline batch."""
368
+ data = _load_keys_data()
369
+
370
+ if body.batch:
371
+ # Inline batch import
372
+ timestamp = datetime.utcnow().isoformat() + "Z"
373
+ new = 0
374
+ dupes = 0
375
+ errors: list[str] = []
376
+ for raw_key in body.batch:
377
+ if not raw_key or not isinstance(raw_key, str):
378
+ errors.append(f"Invalid key: {raw_key}")
379
+ continue
380
+ key = raw_key.strip()
381
+ if not key:
382
+ errors.append("Empty key in batch")
383
+ continue
384
+ if key in data.get("keys", {}):
385
+ dupes += 1
386
+ continue
387
+ data.setdefault("keys", {})[key] = {
388
+ "key_masked": mask_key(key),
389
+ "provider": "unknown",
390
+ "provider_detected": None,
391
+ "status": "unknown",
392
+ "sources": [{"file": "batch", "batch": "manual", "imported_at": timestamp}],
393
+ "checks": [],
394
+ "tests": {},
395
+ "first_seen": timestamp,
396
+ "last_checked": None,
397
+ "last_tested": None,
398
+ "created_at": timestamp,
399
+ }
400
+ new += 1
401
+ _save_keys_data(data)
402
+ project_logger.log_web_action("import", f"batch: {new} new, {dupes} dupes")
403
+ return ImportResponse(new=new, duplicates=dupes, errors=errors)
404
+
405
+ allowed_dirs = config.get("scan", {}).get("directories", ["./data/input"])
406
+ if body.file:
407
+ validate_import_path(body.file, allowed_dirs)
408
+ if body.directory:
409
+ validate_import_path(body.directory, allowed_dirs)
410
+
411
+ if not body.file and not body.directory:
412
+ body.directory = config["scan"]["directories"][0]
413
+
414
+ new, dupes, errors = import_keys(
415
+ file_path=body.file,
416
+ directory=body.directory,
417
+ batch=None,
418
+ keys_file=config["storage"]["keys_file"],
419
+ )
420
+ project_logger.log_web_action("import", f"{body.file or body.directory}: {new} new, {dupes} dupes")
421
+ return ImportResponse(new=new, duplicates=dupes, errors=errors)
422
+
423
+
424
+ @app.post("/api/import/upload", tags=["Keys"])
425
+ async def api_import_upload(request: Request):
426
+ """Upload and import a JSON key file."""
427
+ file = None
428
+ filename = ""
429
+ try:
430
+ form = await request.form()
431
+ file = form.get("file")
432
+ if hasattr(file, "filename"):
433
+ filename = file.filename or ""
434
+ except Exception:
435
+ pass
436
+
437
+ if not file or not filename:
438
+ response = ErrorResponse.error_factory(
439
+ code=ErrorCode.VALIDATION_FILE_NOT_FOUND,
440
+ message=t("VALIDATION_FILE_NOT_FOUND"),
441
+ )
442
+ return JSONResponse(status_code=400, content=response.model_dump())
443
+
444
+ if not filename.endswith(".json"):
445
+ raise ValidationError(
446
+ code=ErrorCode.VALIDATION_FILE_FORMAT,
447
+ message=t("VALIDATION_FILE_FORMAT"),
448
+ )
449
+
450
+ content = await file.read()
451
+ try:
452
+ items = json.loads(content)
453
+ except json.JSONDecodeError:
454
+ raise ValidationError(
455
+ code=ErrorCode.VALIDATION_FILE_FORMAT,
456
+ message="Uploaded file is not valid JSON",
457
+ )
458
+
459
+ if not isinstance(items, list):
460
+ raise ValidationError(
461
+ code=ErrorCode.VALIDATION_FILE_FORMAT,
462
+ message="Uploaded JSON must be an array of key objects",
463
+ )
464
+
465
+ keys_file = config["storage"]["keys_file"]
466
+ logs_dir = config["storage"].get("logs_dir", "./data/logs")
467
+ allowed_dirs = [logs_dir, "./data/input"]
468
+ tmp_path = validate_import_path(str(Path(logs_dir).parent / "input" / filename), allowed_dirs)
469
+ tmp_path.parent.mkdir(parents=True, exist_ok=True)
470
+ tmp_path.write_bytes(content)
471
+
472
+ new, dupes, errors = import_keys(
473
+ file_path=str(tmp_path),
474
+ keys_file=keys_file,
475
+ )
476
+
477
+ # Clean up temp file
478
+ try:
479
+ tmp_path.unlink()
480
+ except Exception:
481
+ pass
482
+
483
+ project_logger.log_web_action("import_upload", f"{filename}: {new} new, {dupes} dupes")
484
+ return ImportResponse(new=new, duplicates=dupes, errors=errors)
485
+
486
+
487
+ @app.get("/api/keys", tags=["Keys"], response_model=KeyListResponse)
488
+ async def api_list_keys(
489
+ provider: str = Query(None, description="Filter by provider"),
490
+ status: str = Query(None, description="Filter by status"),
491
+ batch: str = Query(None, description="Filter by batch label"),
492
+ page: int = Query(1, ge=1, description="Page number"),
493
+ page_size: int = Query(50, ge=1, le=200, description="Items per page"),
494
+ ):
495
+ """List keys with optional filters and pagination."""
496
+ data = _load_keys_data()
497
+ keys_dict = data.get("keys", {})
498
+
499
+ filtered: list[tuple[str, dict]] = []
500
+ for key, info in keys_dict.items():
501
+ if provider and info.get("provider", "").lower() != provider.lower():
502
+ continue
503
+ if status and info.get("status") != status:
504
+ continue
505
+ if batch:
506
+ has_batch = any(s.get("batch") == batch for s in info.get("sources", []))
507
+ if not has_batch:
508
+ continue
509
+ filtered.append((key, info))
510
+
511
+ total = len(filtered)
512
+ total_pages = max(1, (total + page_size - 1) // page_size)
513
+ start = (page - 1) * page_size
514
+ end = start + page_size
515
+ paged = filtered[start:end]
516
+
517
+ key_infos: list[KeyInfo] = []
518
+ for key, info in paged:
519
+ key_infos.append(KeyInfo(
520
+ key=key,
521
+ key_masked=info.get("key_masked", mask_key(key)),
522
+ provider=info.get("provider", "unknown"),
523
+ status=info.get("status", "unknown"),
524
+ last_checked=info.get("last_checked"),
525
+ last_error=info.get("last_error"),
526
+ error_type=info.get("error_type"),
527
+ tests=info.get("tests", {}),
528
+ models=info.get("tests", {}).get("models", []),
529
+ sources_count=len(info.get("sources", [])),
530
+ balance=info.get("balance"),
531
+ ))
532
+
533
+ return KeyListResponse(
534
+ keys=key_infos,
535
+ total=total,
536
+ page=page,
537
+ total_pages=total_pages,
538
+ page_size=page_size,
539
+ )
540
+
541
+
542
+ @app.get("/api/keys/export", tags=["Keys"], response_model=KeyExportResponse)
543
+ async def api_export_keys(
544
+ provider: str = Query(None, description="Filter by provider"),
545
+ ):
546
+ """Export all valid keys."""
547
+ data = _load_keys_data()
548
+ keys_dict = data.get("keys", {})
549
+
550
+ exported: list[KeyExportItem] = []
551
+ for key, info in keys_dict.items():
552
+ if info.get("status") != "valid":
553
+ continue
554
+ if provider and info.get("provider", "").lower() != provider.lower():
555
+ continue
556
+ tests = info.get("tests", {})
557
+ exported.append(KeyExportItem(
558
+ key=key,
559
+ provider=info.get("provider", "unknown"),
560
+ max_tokens=tests.get("max_tokens"),
561
+ max_concurrency=tests.get("max_concurrency"),
562
+ ))
563
+
564
+ project_logger.log_web_action("export", f"{len(exported)} keys")
565
+ return KeyExportResponse(keys=exported, total=len(exported))
566
+
567
+
568
+ @app.post("/api/keys/clear", tags=["Keys"])
569
+ async def api_clear_keys():
570
+ """Clear all keys from storage."""
571
+ cfg = config
572
+ keys_path = Path(cfg["storage"]["keys_file"])
573
+ cleared = 0
574
+ if keys_path.exists():
575
+ data = _load_keys_data()
576
+ cleared = len(data.get("keys", {}))
577
+ data["keys"] = {}
578
+ _save_keys_data(data)
579
+ project_logger.log_web_action("clear", f"{cleared} keys removed")
580
+ return {"cleared": cleared}
581
+
582
+
583
+ # ══════════════════════════════════════════════════════════════════════════════
584
+ # CHECK
585
+ # ══════════════════════════════════════════════════════════════════════════════
586
+
587
+ @app.post("/api/check", tags=["Check"])
588
+ async def api_check(
589
+ provider: str = Form(None),
590
+ status: str = Form(None),
591
+ body_json: dict = Body(None),
592
+ ):
593
+ """Run a validation check against all keys (synchronous - returns results directly)."""
594
+ # Support both form data and JSON body
595
+ p = provider or (body_json or {}).get("provider")
596
+ s = status or (body_json or {}).get("status")
597
+
598
+ proxy = get_proxy(config.get("proxy")) or None
599
+ results = await validate_keys(
600
+ keys_file=config["storage"]["keys_file"],
601
+ results_file=config["storage"]["check_results_file"],
602
+ logs_dir=config["storage"]["logs_dir"],
603
+ concurrency=config["check"]["concurrency"],
604
+ timeout=config["check"]["timeout_seconds"],
605
+ proxy=proxy,
606
+ provider_filter=p or None,
607
+ status_filter=s or None,
608
+ progress_callback=_make_progress_callback(),
609
+ )
610
+ project_logger.log_web_action("check_all", f"total={results.get('total', 0)}")
611
+
612
+ # Dispatch webhook
613
+ asyncio.create_task(
614
+ webhook_manager.dispatch(
615
+ WebhookEvent.BATCH_CHECK_COMPLETED,
616
+ {"total": results.get("total", 0)},
617
+ )
618
+ )
619
+
620
+ return results
621
+
622
+
623
+ @app.post("/api/check/single", tags=["Check"], response_model=CheckSingleResponse)
624
+ async def api_check_single(body: CheckSingleRequest):
625
+ """Check validity of a single API key."""
626
+ key = (body.key or "").strip()
627
+ provider_name = (body.provider or "").strip()
628
+
629
+ if not key:
630
+ raise ValidationError(
631
+ code=ErrorCode.VALIDATION_MISSING_KEY,
632
+ message=t("VALIDATION_MISSING_KEY"),
633
+ )
634
+
635
+ # Detect provider if not given �use smart detection with probe+check verification
636
+ proxy = get_proxy(config.get("proxy")) or None
637
+
638
+ # Debug logging for proxy config
639
+ if DEBUG_ENABLED and debug_logger:
640
+ import asyncio as _asyncio
641
+ _asyncio.create_task(debug_logger.log(
642
+ category="DETECT",
643
+ action="config",
644
+ detail=f"proxy={proxy}, timeout={config['check']['timeout_seconds']}s",
645
+ data={"proxy": proxy, "timeout": config['check']['timeout_seconds']},
646
+ level="INFO"
647
+ ))
648
+
649
+ async with httpx.AsyncClient(
650
+ timeout=config["check"]["timeout_seconds"],
651
+ proxy=proxy,
652
+ follow_redirects=False,
653
+ ) as client:
654
+ if not provider_name:
655
+ provider_name = await detect_provider(client, key)
656
+ if not provider_name or provider_name == 'unknown':
657
+ raise ValidationError(
658
+ code=ErrorCode.VALIDATION_PROVIDER_UNKNOWN,
659
+ message=t("VALIDATION_PROVIDER_UNKNOWN"),
660
+ )
661
+
662
+ provider_name_lower = provider_name.lower()
663
+ provider_obj = PROVIDERS.get(provider_name_lower)
664
+ if not provider_obj:
665
+ raise ValidationError(
666
+ code=ErrorCode.VALIDATION_PROVIDER_UNKNOWN,
667
+ message=t("VALIDATION_PROVIDER_UNKNOWN"),
668
+ )
669
+
670
+ result = await provider_obj.check(client, key)
671
+
672
+ # Attempt balance query
673
+ balance = None
674
+ if result.valid and hasattr(provider_obj, "get_balance"):
675
+ try:
676
+ bal = await provider_obj.get_balance(client, key)
677
+ if bal.supported and bal.balance is not None:
678
+ balance = {"balance": bal.balance, "currency": bal.currency}
679
+ except Exception:
680
+ pass
681
+
682
+ # Attempt models query
683
+ models: list[str] = []
684
+ if result.valid and hasattr(provider_obj, "get_models"):
685
+ try:
686
+ models = await provider_obj.get_models(client, key) or []
687
+ except Exception:
688
+ pass
689
+
690
+ error_type = None
691
+ if not result.valid:
692
+ if result.status_code in (401, 403):
693
+ error_type = "invalid_key"
694
+ elif result.status_code == 429:
695
+ error_type = "rate_limited"
696
+ elif result.status_code == 402:
697
+ error_type = "insufficient_balance"
698
+
699
+ status_str = "valid" if result.valid else ("invalid" if result.status_code in (401, 403) else "error")
700
+
701
+ project_logger.log_web_action("check_single", f"{mask_key(key)} {provider_name}: {status_str}")
702
+
703
+ # Simplify error message for readability
704
+ simplified_error = simplify_error(result.error, result.status_code) if result.error else None
705
+
706
+ return CheckSingleResponse(
707
+ key=key,
708
+ key_masked=mask_key(key),
709
+ provider=provider_name,
710
+ display_name=get_display_name(provider_name),
711
+ status=status_str,
712
+ status_code=result.status_code,
713
+ latency_ms=result.latency_ms,
714
+ error=simplified_error,
715
+ error_type=error_type,
716
+ balance=balance,
717
+ models=models,
718
+ )
719
+
720
+
721
+ @app.post("/api/check/batch", tags=["Check"], response_model=CheckBatchResponse)
722
+ async def api_check_batch(body: CheckBatchRequest):
723
+ """Check validity of multiple keys in batch."""
724
+ if not body.keys:
725
+ raise ValidationError(
726
+ code=ErrorCode.VALIDATION_MISSING_KEY,
727
+ message=t("VALIDATION_MISSING_KEY"),
728
+ )
729
+
730
+ proxy = get_proxy(config.get("proxy")) or None
731
+ results: list[CheckBatchResult] = []
732
+ summary = CheckBatchSummary()
733
+
734
+ sem = asyncio.Semaphore(body.concurrency or 50)
735
+
736
+ async def _check_one(item: CheckBatchItem):
737
+ key = (item.key or "").strip()
738
+ provider_name = (item.provider or "").strip()
739
+
740
+ if not key:
741
+ return CheckBatchResult(
742
+ key_masked="(empty)",
743
+ provider="unknown",
744
+ status="error",
745
+ error="Empty key provided",
746
+ )
747
+
748
+ if not provider_name:
749
+ # Use detect_provider for robust detection
750
+ try:
751
+ async with httpx.AsyncClient(timeout=10, proxy=proxy) as detect_client:
752
+ provider_name = await detect_provider(detect_client, key)
753
+ except Exception:
754
+ pass
755
+ if not provider_name or provider_name == 'unknown':
756
+ candidates = detect_by_prefix(key)
757
+ provider_name = candidates[0] if candidates else "unknown"
758
+ else:
759
+ provider_name = provider_name.lower()
760
+
761
+ provider_obj = PROVIDERS.get(provider_name)
762
+ if not provider_obj:
763
+ return CheckBatchResult(
764
+ key_masked=mask_key(key),
765
+ provider=provider_name,
766
+ status="error",
767
+ error=f"Unknown provider: {provider_name}",
768
+ )
769
+
770
+ async with sem:
771
+ async with httpx.AsyncClient(
772
+ timeout=body.timeout or 10,
773
+ proxy=proxy,
774
+ follow_redirects=False,
775
+ ) as client:
776
+ result = await provider_obj.check(client, key)
777
+
778
+ status_str = "valid" if result.valid else ("invalid" if result.status_code in (401, 403) else "error")
779
+ return CheckBatchResult(
780
+ key_masked=mask_key(key),
781
+ provider=provider_name,
782
+ status=status_str,
783
+ status_code=result.status_code,
784
+ latency_ms=result.latency_ms,
785
+ error=result.error,
786
+ error_type=getattr(result, "error_type", None),
787
+ )
788
+
789
+ tasks = [_check_one(item) for item in body.keys]
790
+ batch_results = await asyncio.gather(*tasks)
791
+
792
+ for r in batch_results:
793
+ results.append(r)
794
+ if r.status == "valid":
795
+ summary.valid += 1
796
+ elif r.status == "invalid":
797
+ summary.invalid += 1
798
+ else:
799
+ summary.error += 1
800
+
801
+ summary.total = len(batch_results)
802
+ project_logger.log_web_action("check_batch", f"total={summary.total}, valid={summary.valid}")
803
+
804
+ return CheckBatchResponse(results=results, summary=summary)
805
+
806
+
807
+ # ══════════════════════════════════════════════════════════════════════════════
808
+ # TEST
809
+ # ══════════════════════════════════════════════════════════════════════════════
810
+
811
+ @app.post("/api/test", tags=["Test"])
812
+ async def api_test():
813
+ """Run token limit and concurrency tests against all valid keys (async)."""
814
+ proxy = get_proxy(config.get("proxy")) or None
815
+
816
+ async def _run():
817
+ try:
818
+ results = await run_test(
819
+ keys_file=config["storage"]["keys_file"],
820
+ results_file=config["storage"]["test_results_file"],
821
+ logs_dir=config["storage"]["logs_dir"],
822
+ timeout=config["test"]["concurrency_timeout_seconds"],
823
+ proxy=proxy,
824
+ token_test=config["test"].get("token_test", True),
825
+ concurrency_test=config["test"].get("concurrency_test", True),
826
+ token_steps=config["test"]["token_steps"],
827
+ concurrency_steps=config["test"]["concurrency_steps"],
828
+ progress_callback=_make_progress_callback(),
829
+ )
830
+ _progress_tracker.done("done", results)
831
+ project_logger.log_web_action("test_all", f"tested={results.get('total_tested', 0)}")
832
+ await webhook_manager.dispatch(
833
+ WebhookEvent.BATCH_TEST_COMPLETED,
834
+ {"total_tested": results.get("total_tested", 0)},
835
+ )
836
+ except Exception as e:
837
+ _progress_tracker.done("error", {"error": str(e)})
838
+ await webhook_manager.dispatch(
839
+ WebhookEvent.ERROR_OCCURRED,
840
+ {"error": str(e)},
841
+ )
842
+
843
+ _progress_tracker.start(total=0, status="loading")
844
+ asyncio.create_task(_run())
845
+ return {"message": "Test started", "status": "loading"}
846
+
847
+
848
+ @app.post("/api/test/single", tags=["Test"], response_model=TestSingleResponse)
849
+ async def api_test_single(body: TestSingleRequest):
850
+ """Test token limit and concurrency for a single key."""
851
+ key = (body.key or "").strip()
852
+ provider_name = (body.provider or "").strip()
853
+
854
+ if not key:
855
+ raise ValidationError(
856
+ code=ErrorCode.VALIDATION_MISSING_KEY,
857
+ message=t("VALIDATION_MISSING_KEY"),
858
+ )
859
+
860
+ if not provider_name:
861
+ proxy = get_proxy(config.get("proxy")) or None
862
+ async with httpx.AsyncClient(timeout=config["check"]["timeout_seconds"], proxy=proxy) as client:
863
+ provider_name = await detect_provider(client, key)
864
+ if not provider_name or provider_name == 'unknown':
865
+ raise ValidationError(
866
+ code=ErrorCode.VALIDATION_PROVIDER_UNKNOWN,
867
+ message=t("VALIDATION_PROVIDER_UNKNOWN"),
868
+ )
869
+
870
+ provider_name_lower = provider_name.lower()
871
+ provider_obj = PROVIDERS.get(provider_name_lower)
872
+ if not provider_obj:
873
+ raise ValidationError(
874
+ code=ErrorCode.VALIDATION_PROVIDER_UNKNOWN,
875
+ message=t("VALIDATION_PROVIDER_UNKNOWN"),
876
+ )
877
+
878
+ proxy = get_proxy(config.get("proxy")) or None
879
+ token_steps = config["test"]["token_steps"]
880
+ concurrency_steps = config["test"]["concurrency_steps"]
881
+
882
+ max_tokens = None
883
+ max_concurrency = None
884
+ models: list[str] = []
885
+ error = None
886
+
887
+ async with httpx.AsyncClient(
888
+ timeout=config["test"]["concurrency_timeout_seconds"],
889
+ proxy=proxy,
890
+ ) as client:
891
+ # Token test
892
+ try:
893
+ token_result = await provider_obj.test_token_limit(client, key, token_steps)
894
+ max_tokens = token_result.max_tokens
895
+ if token_result.error:
896
+ error = token_result.error
897
+ except Exception as e:
898
+ if not error:
899
+ error = str(e)
900
+
901
+ # Concurrency test
902
+ try:
903
+ conc_result = await provider_obj.test_concurrency(client, key, concurrency_steps)
904
+ max_concurrency = conc_result.max_concurrency
905
+ if conc_result.error and not error:
906
+ error = conc_result.error
907
+ except Exception as e:
908
+ if not error:
909
+ error = str(e)
910
+
911
+ # Models
912
+ try:
913
+ if hasattr(provider_obj, "get_models"):
914
+ models = await provider_obj.get_models(client, key) or []
915
+ except Exception:
916
+ pass
917
+
918
+ project_logger.log_web_action(
919
+ "test_single",
920
+ f"{mask_key(key)} {provider_name}: tokens={max_tokens}, concurrency={max_concurrency}",
921
+ )
922
+
923
+ return TestSingleResponse(
924
+ provider=provider_name,
925
+ key_masked=mask_key(key),
926
+ max_tokens=max_tokens,
927
+ max_concurrency=max_concurrency,
928
+ models=models,
929
+ error=error,
930
+ )
931
+
932
+
933
+ @app.post("/api/test/token", tags=["Test"])
934
+ async def api_test_token():
935
+ """Run token limit tests on all valid keys (async)."""
936
+ proxy = get_proxy(config.get("proxy")) or None
937
+
938
+ async def _run():
939
+ try:
940
+ results = await run_test(
941
+ keys_file=config["storage"]["keys_file"],
942
+ results_file=config["storage"]["test_results_file"],
943
+ logs_dir=config["storage"]["logs_dir"],
944
+ timeout=config["test"]["concurrency_timeout_seconds"],
945
+ proxy=proxy,
946
+ token_test=True,
947
+ concurrency_test=False,
948
+ token_steps=config["test"]["token_steps"],
949
+ progress_callback=_make_progress_callback(),
950
+ )
951
+ _progress_tracker.done("done", results)
952
+ except Exception as e:
953
+ _progress_tracker.done("error", {"error": str(e)})
954
+
955
+ _progress_tracker.start(total=0, status="loading")
956
+ asyncio.create_task(_run())
957
+ return {"message": "Token test started", "status": "loading"}
958
+
959
+
960
+ @app.post("/api/test/token/batch", tags=["Test"], include_in_schema=False)
961
+ async def api_test_token_batch():
962
+ """Run token limit tests on specific keys (batch alias)."""
963
+ return await api_test_token()
964
+
965
+
966
+ @app.post("/api/test/concurrency", tags=["Test"])
967
+ async def api_test_concurrency():
968
+ """Run concurrency tests on all valid keys (async)."""
969
+ proxy = get_proxy(config.get("proxy")) or None
970
+
971
+ async def _run():
972
+ try:
973
+ results = await run_test(
974
+ keys_file=config["storage"]["keys_file"],
975
+ results_file=config["storage"]["test_results_file"],
976
+ logs_dir=config["storage"]["logs_dir"],
977
+ timeout=config["test"]["concurrency_timeout_seconds"],
978
+ proxy=proxy,
979
+ token_test=False,
980
+ concurrency_test=True,
981
+ concurrency_steps=config["test"]["concurrency_steps"],
982
+ progress_callback=_make_progress_callback(),
983
+ )
984
+ _progress_tracker.done("done", results)
985
+ except Exception as e:
986
+ _progress_tracker.done("error", {"error": str(e)})
987
+
988
+ _progress_tracker.start(total=0, status="loading")
989
+ asyncio.create_task(_run())
990
+ return {"message": "Concurrency test started", "status": "loading"}
991
+
992
+
993
+ @app.post("/api/test/concurrency/batch", tags=["Test"], include_in_schema=False)
994
+ async def api_test_concurrency_batch():
995
+ """Run concurrency tests on specific keys (batch alias)."""
996
+ return await api_test_concurrency()
997
+
998
+ # ══════════════════════════════════════════════════════════════════════════════
999
+ # BALANCE
1000
+ # ══════════════════════════════════════════════════════════════════════════════
1001
+
1002
+ @app.post("/api/balance", tags=["Balance"], response_model=BalanceResponse)
1003
+ async def api_balance(body: BalanceRequest):
1004
+ """Query account balance for a key."""
1005
+ key = (body.key or "").strip()
1006
+ provider_name = (body.provider or "").strip()
1007
+
1008
+ if not key:
1009
+ raise ValidationError(
1010
+ code=ErrorCode.VALIDATION_MISSING_KEY,
1011
+ message=t("VALIDATION_MISSING_KEY"),
1012
+ )
1013
+
1014
+ if not provider_name:
1015
+ proxy = get_proxy(config.get("proxy")) or None
1016
+ async with httpx.AsyncClient(timeout=config["check"]["timeout_seconds"], proxy=proxy) as client:
1017
+ provider_name = await detect_provider(client, key)
1018
+ if not provider_name or provider_name == 'unknown':
1019
+ raise ValidationError(
1020
+ code=ErrorCode.VALIDATION_PROVIDER_UNKNOWN,
1021
+ message=t("VALIDATION_PROVIDER_UNKNOWN"),
1022
+ )
1023
+
1024
+ provider_name_lower = provider_name.lower()
1025
+ provider_obj = PROVIDERS.get(provider_name_lower)
1026
+ if not provider_obj:
1027
+ raise ValidationError(
1028
+ code=ErrorCode.VALIDATION_PROVIDER_UNKNOWN,
1029
+ message=t("VALIDATION_PROVIDER_UNKNOWN"),
1030
+ )
1031
+
1032
+ proxy = get_proxy(config.get("proxy")) or None
1033
+ error = None
1034
+ balance_value = None
1035
+ currency = None
1036
+ supported = False
1037
+
1038
+ if hasattr(provider_obj, "get_balance"):
1039
+ supported = True
1040
+ async with httpx.AsyncClient(
1041
+ timeout=config["check"]["timeout_seconds"],
1042
+ proxy=proxy,
1043
+ ) as client:
1044
+ try:
1045
+ bal = await provider_obj.get_balance(client, key)
1046
+ if bal.supported:
1047
+ balance_value = bal.balance
1048
+ currency = bal.currency
1049
+ else:
1050
+ supported = False
1051
+ if bal.error:
1052
+ error = bal.error
1053
+ except Exception as e:
1054
+ error = str(e)
1055
+ else:
1056
+ error = "Provider does not support balance queries"
1057
+
1058
+ project_logger.log_web_action("balance", f"{mask_key(key)} {provider_name}: {balance_value}")
1059
+
1060
+ return BalanceResponse(
1061
+ provider=provider_name,
1062
+ supported=supported,
1063
+ balance=balance_value,
1064
+ currency=currency,
1065
+ key_masked=mask_key(key),
1066
+ error=error,
1067
+ )
1068
+
1069
+
1070
+ # ══════════════════════════════════════════════════════════════════════════════
1071
+ # MODELS
1072
+ # ══════════════════════════════════════════════════════════════════════════════
1073
+
1074
+ @app.get("/api/models", tags=["Models"], response_model=ModelsResponse)
1075
+ async def api_models(
1076
+ provider: str = Query(None, description="Provider name"),
1077
+ type_filter: str = Query("all", description="Model type filter"),
1078
+ key: str = Query(None, description="API key for live model fetch"),
1079
+ ):
1080
+ """Get available models for a provider (static or live)."""
1081
+ provider_name = (provider or "").lower()
1082
+
1083
+ if not provider_name:
1084
+ # Try to detect provider from key if provided
1085
+ if key:
1086
+ proxy = get_proxy(config.get("proxy")) or None
1087
+ async with httpx.AsyncClient(
1088
+ timeout=config["check"]["timeout_seconds"],
1089
+ proxy=proxy,
1090
+ ) as client:
1091
+ detected = await detect_provider(client, key)
1092
+ if detected and detected != 'unknown' and detected in PROVIDERS:
1093
+ provider_name = detected
1094
+ provider_obj = PROVIDERS[provider_name]
1095
+ # Fall through to live model fetch below
1096
+ else:
1097
+ return ModelsResponse(
1098
+ provider="unknown",
1099
+ models=[],
1100
+ total=0,
1101
+ type_filter=type_filter,
1102
+ source=None,
1103
+ hint="无法ä»?Key 自动检æµ?Provider,请手动选择",
1104
+ )
1105
+ else:
1106
+ # No key provided, return all static models
1107
+ all_models: list[str] = []
1108
+ for p in PROVIDERS.values():
1109
+ if hasattr(p, "models"):
1110
+ all_models.extend(getattr(p, "models", []))
1111
+ return ModelsResponse(
1112
+ provider="all",
1113
+ models=sorted(set(all_models)),
1114
+ total=len(set(all_models)),
1115
+ type_filter=type_filter,
1116
+ source="static",
1117
+ )
1118
+
1119
+ provider_obj = PROVIDERS.get(provider_name)
1120
+ if not provider_obj:
1121
+ return ModelsResponse(
1122
+ provider=provider_name,
1123
+ models=[],
1124
+ total=0,
1125
+ type_filter=type_filter,
1126
+ source=None,
1127
+ hint="Provider not found",
1128
+ )
1129
+
1130
+ models: list[str] = []
1131
+ source = "static"
1132
+
1133
+ if key and hasattr(provider_obj, "get_models"):
1134
+ proxy = get_proxy(config.get("proxy")) or None
1135
+ async with httpx.AsyncClient(
1136
+ timeout=config["check"]["timeout_seconds"],
1137
+ proxy=proxy,
1138
+ ) as client:
1139
+ try:
1140
+ models = await provider_obj.get_models(client, key) or []
1141
+ source = "api"
1142
+ except Exception:
1143
+ pass
1144
+
1145
+ if not models and hasattr(provider_obj, "models"):
1146
+ models = getattr(provider_obj, "models", [])
1147
+
1148
+ # Apply type filter (if model_capabilities module is available)
1149
+ filtered = models
1150
+ try:
1151
+ from key_manager.model_capabilities import detector
1152
+ await detector.load()
1153
+ if type_filter == "vision":
1154
+ filtered = [m for m in models if detector.is_vision_model(m)]
1155
+ elif type_filter == "tool":
1156
+ filtered = [m for m in models if detector.is_tool_model(m)]
1157
+ except Exception:
1158
+ pass
1159
+
1160
+ return ModelsResponse(
1161
+ provider=provider_name,
1162
+ models=filtered,
1163
+ total=len(filtered),
1164
+ type_filter=type_filter,
1165
+ source=source,
1166
+ )
1167
+
1168
+
1169
+ @app.post("/api/models/check", tags=["Models"])
1170
+ async def api_models_check(request: Request):
1171
+ """Live model availability check (SSE stream)."""
1172
+ try:
1173
+ body = await request.json()
1174
+ except Exception:
1175
+ raise ValidationError(code=ErrorCode.VALIDATION_INVALID_FORMAT, message="Invalid JSON body")
1176
+
1177
+ provider_name = (body.get("provider") or "").lower()
1178
+ key = (body.get("key") or "").strip()
1179
+ if not key:
1180
+ raise ValidationError(code=ErrorCode.VALIDATION_MISSING_KEY, message=t("VALIDATION_MISSING_KEY"))
1181
+
1182
+ proxy = get_proxy(config.get("proxy")) or None
1183
+ provider_obj = None
1184
+ models: list[str] = []
1185
+ source = "api"
1186
+
1187
+ # Step 1: Find provider and get models
1188
+ if provider_name:
1189
+ provider_obj = PROVIDERS.get(provider_name)
1190
+ if provider_obj:
1191
+ async with httpx.AsyncClient(timeout=config["check"]["timeout_seconds"], proxy=proxy) as client:
1192
+ try:
1193
+ models = await provider_obj.get_models(client, key) or []
1194
+ except Exception:
1195
+ pass
1196
+ else:
1197
+ # Use detect_provider for robust auto-detection
1198
+ async with httpx.AsyncClient(timeout=config["check"]["timeout_seconds"], proxy=proxy) as client:
1199
+ detected = await detect_provider(client, key)
1200
+ if detected and detected != 'unknown':
1201
+ provider_name = detected
1202
+ provider_obj = PROVIDERS.get(provider_name)
1203
+ if provider_obj:
1204
+ try:
1205
+ models = await provider_obj.get_models(client, key) or []
1206
+ except Exception:
1207
+ pass
1208
+
1209
+ # Step 2: Fallback to static models
1210
+ if not models and provider_obj:
1211
+ source = "static"
1212
+ if hasattr(provider_obj, "models"):
1213
+ models = getattr(provider_obj, "models", [])
1214
+ if not provider_name:
1215
+ candidates = detect_by_prefix(key)
1216
+ provider_name = candidates[0] if candidates else "unknown"
1217
+ provider_obj = PROVIDERS.get(provider_name)
1218
+
1219
+ # Step 3: SSE response
1220
+ if not models:
1221
+ async def empty():
1222
+ yield f'data: {{"type":"complete","provider":"{provider_name}","available":0,"total":0,"source":"{source}"}}\n\n'
1223
+ return StreamingResponse(empty(), media_type="text/event-stream", headers={"Cache-Control":"no-cache","X-Accel-Buffering":"no"})
1224
+
1225
+ async def stream():
1226
+ # Phase 1: Report models found
1227
+ yield f'data: {{"type":"progress","current":0,"total":{len(models)},"model":"","mode":"parallel"}}\n\n'
1228
+
1229
+ # Phase 2: Check all models
1230
+ available_count = 0
1231
+ timeout_count = 0
1232
+ all_available_models = set()
1233
+ failed_models = []
1234
+
1235
+ async def check_model(http, model):
1236
+ """Use provider's _probe_model method with 10s timeout."""
1237
+ try:
1238
+ result = await asyncio.wait_for(
1239
+ provider_obj._probe_model(http, provider_obj.build_headers(key), model),
1240
+ timeout=10.0
1241
+ )
1242
+ return model, 200 if result else -2
1243
+ except asyncio.TimeoutError:
1244
+ return model, -1
1245
+ except httpx.TimeoutException:
1246
+ return model, -1
1247
+ except Exception:
1248
+ return model, -2
1249
+
1250
+ async with httpx.AsyncClient(proxy=proxy) as http:
1251
+ # Step 1: Parallel check with dynamic batch_size
1252
+ # Start from 5, +1 on each success, stay same on failure
1253
+ batch_size = 5
1254
+ i = 0
1255
+ while i < len(models):
1256
+ batch = models[i:i+batch_size]
1257
+ yield f'data: {{"type":"progress","current":{i+1},"total":{len(models)},"model":"{batch[0]}","mode":"parallel","batch_size":{len(batch)}}}\n\n'
1258
+
1259
+ results = await asyncio.gather(*[check_model(http, m) for m in batch])
1260
+
1261
+ batch_success = True
1262
+ for model, code in results:
1263
+ if code == 200:
1264
+ available_count += 1
1265
+ all_available_models.add(model)
1266
+ yield f'data: {{"type":"result","model":"{model}","available":true,"status":"available"}}\n\n'
1267
+ else:
1268
+ failed_models.append(model)
1269
+ batch_success = False
1270
+ yield f'data: {{"type":"model_timeout","model":"{model}"}}\n\n'
1271
+
1272
+ # Adjust batch_size: +1 if all success, stay same if any failure
1273
+ if batch_success:
1274
+ batch_size += 1
1275
+ i += len(batch)
1276
+ # Step 2: Serial retry failed models
1277
+ if failed_models:
1278
+ yield f'data: {{"type":"serial_mode","reason":"retry_failed"}}\n\n'
1279
+ await asyncio.sleep(0.5)
1280
+
1281
+ for model in failed_models[:]:
1282
+ if model in all_available_models:
1283
+ continue
1284
+
1285
+ _, code = await check_model(http, model)
1286
+
1287
+ if code == 200:
1288
+ available_count += 1
1289
+ all_available_models.add(model)
1290
+ failed_models.remove(model)
1291
+ yield f'data: {{"type":"result","model":"{model}","available":true,"status":"available","retry":true}}\n\n'
1292
+ else:
1293
+ timeout_count += 1
1294
+ yield f'data: {{"type":"model_timeout","model":"{model}","retry":true}}\n\n'
1295
+
1296
+ # Final summary
1297
+ yield f'data: {{"type":"complete","provider":"{provider_name}","available":{available_count},"total":{len(models)},"timeout":{timeout_count},"source":"{source}"}}\n\n'
1298
+
1299
+ return StreamingResponse(stream(), media_type="text/event-stream", headers={"Cache-Control":"no-cache","Connection":"keep-alive","X-Accel-Buffering":"no"})
1300
+
1301
+
1302
+ # ══════════════════════════════════════════════════════════════════════════════
1303
+ # PROVIDERS
1304
+ # ══════════════════════════════════════════════════════════════════════════════
1305
+
1306
+ @app.get("/api/providers", tags=["Providers"], response_model=ProvidersResponse)
1307
+ async def api_providers():
1308
+ """List all registered providers."""
1309
+ provider_list: list[ProviderInfoModel] = []
1310
+ for name, p in PROVIDERS.items():
1311
+ prefix = ""
1312
+ for pf, providers in KEY_PREFIX_MAP.items():
1313
+ if name in providers:
1314
+ prefix = pf
1315
+ break
1316
+ provider_list.append(ProviderInfoModel(
1317
+ name=name,
1318
+ display_name=get_display_name(name),
1319
+ prefix=prefix or "-",
1320
+ base_url=getattr(p, "base_url", ""),
1321
+ type="ai",
1322
+ ))
1323
+ return ProvidersResponse(providers=provider_list, total=len(provider_list))
1324
+
1325
+
1326
+ @app.get("/api/providers/detail", tags=["Providers"], response_model=ProviderDetailResponse)
1327
+ async def api_providers_detail():
1328
+ """Get detailed provider information including website and docs URLs."""
1329
+ detail_list: list[ProviderDetail] = []
1330
+ for name in PROVIDERS:
1331
+ website = PROVIDER_WEBSITES.get(name, {})
1332
+ prefix = ""
1333
+ for pf, providers in KEY_PREFIX_MAP.items():
1334
+ if name in providers:
1335
+ prefix = pf
1336
+ break
1337
+ detail_list.append(ProviderDetail(
1338
+ name=name,
1339
+ display_name=get_display_name(name),
1340
+ prefix=prefix or "-",
1341
+ base_url=getattr(PROVIDERS[name], "base_url", ""),
1342
+ website_url=website.get("url", ""),
1343
+ docs_url=website.get("docs", ""),
1344
+ website_name=website.get("name", get_display_name(name)),
1345
+ ))
1346
+ return ProviderDetailResponse(providers=detail_list, total=len(detail_list))
1347
+
1348
+
1349
+ # ══════════════════════════════════════════════════════════════════════════════
1350
+ # STATS
1351
+ # ══════════════════════════════════════════════════════════════════════════════
1352
+
1353
+ @app.get("/api/stats", tags=["Stats"], response_model=StatsResponse)
1354
+ async def api_stats():
1355
+ """Get key statistics broken down by provider."""
1356
+ data = _load_keys_data()
1357
+ keys_dict = data.get("keys", {})
1358
+
1359
+ stats: dict[str, StatsProviderEntry] = {}
1360
+ total = 0
1361
+
1362
+ for key, info in keys_dict.items():
1363
+ total += 1
1364
+ provider = info.get("provider", "unknown")
1365
+ status = info.get("status", "unknown")
1366
+
1367
+ if provider not in stats:
1368
+ stats[provider] = StatsProviderEntry(
1369
+ total=0, valid=0, invalid=0, error=0,
1370
+ display_name=get_display_name(provider),
1371
+ )
1372
+
1373
+ stats[provider].total += 1
1374
+ if status == "valid":
1375
+ stats[provider].valid += 1
1376
+ elif status == "invalid":
1377
+ stats[provider].invalid += 1
1378
+ else:
1379
+ stats[provider].error += 1
1380
+
1381
+ return StatsResponse(
1382
+ providers=stats,
1383
+ total=total,
1384
+ )
1385
+
1386
+
1387
+ @app.get("/api/stats/chart", tags=["Stats"], response_model=StatsChartResponse)
1388
+ async def api_stats_chart():
1389
+ """Get chart data for key status distribution."""
1390
+ data = _load_keys_data()
1391
+ keys_dict = data.get("keys", {})
1392
+
1393
+ providers: dict[str, StatsChartProviderEntry] = {}
1394
+ for key, info in keys_dict.items():
1395
+ provider = info.get("provider", "unknown")
1396
+ status = info.get("status", "unknown")
1397
+
1398
+ if provider not in providers:
1399
+ providers[provider] = StatsChartProviderEntry(
1400
+ provider=provider,
1401
+ display_name=get_display_name(provider),
1402
+ statuses=StatsChartStatuses(),
1403
+ )
1404
+
1405
+ if status == "valid":
1406
+ providers[provider].statuses.valid += 1
1407
+ elif status == "invalid":
1408
+ providers[provider].statuses.invalid += 1
1409
+ else:
1410
+ providers[provider].statuses.error += 1
1411
+
1412
+ return StatsChartResponse(
1413
+ providers=list(providers.values()),
1414
+ total=len(keys_dict),
1415
+ )
1416
+
1417
+
1418
+ # ══════════════════════════════════════════════════════════════════════════════
1419
+ # LOGS
1420
+ # ══════════════════════════════════════════════════════════════════════════════
1421
+
1422
+ @app.get("/api/logs", tags=["Logs"], response_model=LogsResponse)
1423
+ async def api_logs():
1424
+ """Get recent log entries."""
1425
+ logs = project_logger.get_recent_logs()
1426
+ return LogsResponse(logs=[LogEntry(message=log) if isinstance(log, str) else LogEntry(**log) for log in logs])
1427
+
1428
+
1429
+ @app.get("/api/logs/operations", tags=["Logs"], response_model=OperationsResponse)
1430
+ async def api_logs_operations():
1431
+ """Get recent operation log entries."""
1432
+ operations = project_logger.get_operations_log()
1433
+ return OperationsResponse(operations=[OperationEntry(**entry) for entry in operations])
1434
+
1435
+
1436
+ # ══════════════════════════════════════════════════════════════════════════════
1437
+ # PROGRESS
1438
+ # ══════════════════════════════════════════════════════════════════════════════
1439
+
1440
+ @app.get("/api/progress", tags=["Progress"], response_model=ProgressResponse)
1441
+ async def api_progress():
1442
+ """Get current progress of long-running operations."""
1443
+ return _progress_tracker.snapshot()
1444
+
1445
+
1446
+ @app.get("/api/progress/stream", tags=["Progress"])
1447
+ async def api_progress_stream():
1448
+ """Stream progress updates via SSE."""
1449
+ return StreamingResponse(
1450
+ _sse_progress_event_generator(),
1451
+ media_type="text/event-stream",
1452
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
1453
+ )
1454
+
1455
+
1456
+ # ── Webhook routes ───────────────────────────────────────────────────────────
1457
+
1458
+ @app.get("/api/webhooks", tags=["Webhooks"])
1459
+ async def api_webhooks_list():
1460
+ """List all configured webhooks."""
1461
+ return webhook_manager.list_webhooks()
1462
+
1463
+
1464
+ @app.post("/api/webhooks", tags=["Webhooks"])
1465
+ async def api_webhooks_create(request: Request):
1466
+ """Create a new webhook."""
1467
+ try:
1468
+ body = await request.json()
1469
+ except Exception:
1470
+ raise ValidationError(code=ErrorCode.VALIDATION_INVALID_FORMAT, message="Invalid JSON body")
1471
+ webhook_manager.add_webhook(body)
1472
+ return {"success": True}
1473
+
1474
+
1475
+ @app.get("/api/webhooks/{webhook_id}", tags=["Webhooks"])
1476
+ async def api_webhooks_get(webhook_id: str):
1477
+ """Get a specific webhook."""
1478
+ webhook = webhook_manager.get_webhook(webhook_id)
1479
+ if not webhook:
1480
+ raise ValidationError(code=ErrorCode.VALIDATION_FILE_NOT_FOUND, message="Webhook not found")
1481
+ return webhook
1482
+
1483
+
1484
+ @app.put("/api/webhooks/{webhook_id}", tags=["Webhooks"])
1485
+ async def api_webhooks_update(webhook_id: str, request: Request):
1486
+ """Update a webhook."""
1487
+ try:
1488
+ body = await request.json()
1489
+ except Exception:
1490
+ raise ValidationError(code=ErrorCode.VALIDATION_INVALID_FORMAT, message="Invalid JSON body")
1491
+ webhook_manager.update_webhook(webhook_id, body)
1492
+ return {"success": True}
1493
+
1494
+
1495
+ @app.delete("/api/webhooks/{webhook_id}", tags=["Webhooks"])
1496
+ async def api_webhooks_delete(webhook_id: str):
1497
+ """Delete a webhook."""
1498
+ webhook_manager.delete_webhook(webhook_id)
1499
+ return {"success": True}
1500
+
1501
+
1502
+ @app.get("/api/webhooks/log/deliveries", tags=["Webhooks"])
1503
+ async def api_webhooks_log_deliveries():
1504
+ """Get webhook delivery logs."""
1505
+ return webhook_manager.get_delivery_logs()
1506
+
1507
+
1508
+ @app.delete("/api/webhooks/log/deliveries", tags=["Webhooks"])
1509
+ async def api_webhooks_log_deliveries_clear():
1510
+ """Clear webhook delivery logs."""
1511
+ webhook_manager.clear_delivery_logs()
1512
+ return {"success": True}