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.
- api_key_manager-2.1.0.dist-info/METADATA +709 -0
- api_key_manager-2.1.0.dist-info/RECORD +73 -0
- api_key_manager-2.1.0.dist-info/WHEEL +5 -0
- api_key_manager-2.1.0.dist-info/entry_points.txt +2 -0
- api_key_manager-2.1.0.dist-info/top_level.txt +1 -0
- key_manager/__init__.py +16 -0
- key_manager/__main__.py +5 -0
- key_manager/api_models.py +358 -0
- key_manager/checker.py +51 -0
- key_manager/cli.py +270 -0
- key_manager/config.py +61 -0
- key_manager/core.py +205 -0
- key_manager/detector.py +335 -0
- key_manager/errors.py +179 -0
- key_manager/i18n.py +142 -0
- key_manager/logger.py +207 -0
- key_manager/model_capabilities.py +412 -0
- key_manager/parser.py +153 -0
- key_manager/providers/__init__.py +283 -0
- key_manager/providers/ai302.py +109 -0
- key_manager/providers/anthropic.py +109 -0
- key_manager/providers/baichuan.py +97 -0
- key_manager/providers/base.py +312 -0
- key_manager/providers/cerebras.py +109 -0
- key_manager/providers/cohere.py +90 -0
- key_manager/providers/cstcloud.py +122 -0
- key_manager/providers/dashscope.py +120 -0
- key_manager/providers/dashscope_coding.py +122 -0
- key_manager/providers/deepseek.py +166 -0
- key_manager/providers/dmxapi.py +109 -0
- key_manager/providers/doubao.py +109 -0
- key_manager/providers/fireworks.py +109 -0
- key_manager/providers/google.py +99 -0
- key_manager/providers/grok.py +109 -0
- key_manager/providers/groq.py +109 -0
- key_manager/providers/huggingface.py +54 -0
- key_manager/providers/hyperbolic.py +109 -0
- key_manager/providers/infini.py +135 -0
- key_manager/providers/infini_coding.py +124 -0
- key_manager/providers/kimi.py +121 -0
- key_manager/providers/kimi_coding.py +124 -0
- key_manager/providers/longcat.py +123 -0
- key_manager/providers/mimo.py +109 -0
- key_manager/providers/mimo_plan.py +140 -0
- key_manager/providers/minimax.py +97 -0
- key_manager/providers/minimax_plan.py +122 -0
- key_manager/providers/mistral.py +109 -0
- key_manager/providers/models_registry.py +2901 -0
- key_manager/providers/modelscope.py +134 -0
- key_manager/providers/nvidia.py +109 -0
- key_manager/providers/ocoolai.py +109 -0
- key_manager/providers/openai.py +140 -0
- key_manager/providers/openrouter.py +119 -0
- key_manager/providers/perplexity.py +109 -0
- key_manager/providers/poe.py +109 -0
- key_manager/providers/ppio.py +109 -0
- key_manager/providers/replicate.py +54 -0
- key_manager/providers/siliconflow.py +121 -0
- key_manager/providers/stepfun.py +132 -0
- key_manager/providers/tencent_hunyuan.py +122 -0
- key_manager/providers/together.py +134 -0
- key_manager/providers/yi.py +97 -0
- key_manager/providers/zai.py +109 -0
- key_manager/providers/zhipu.py +127 -0
- key_manager/providers/zhipu_coding.py +124 -0
- key_manager/proxy.py +70 -0
- key_manager/ssrf.py +68 -0
- key_manager/storage.py +134 -0
- key_manager/tester.py +137 -0
- key_manager/url_override.py +5 -0
- key_manager/validator.py +185 -0
- key_manager/web.py +1512 -0
- 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}
|