webscout 8.3__py3-none-any.whl → 8.3.2__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.
Potentially problematic release.
This version of webscout might be problematic. Click here for more details.
- webscout/AIauto.py +4 -4
- webscout/AIbase.py +61 -1
- webscout/AIutel.py +46 -53
- webscout/Bing_search.py +418 -0
- webscout/Extra/YTToolkit/ytapi/patterns.py +45 -45
- webscout/Extra/YTToolkit/ytapi/stream.py +1 -1
- webscout/Extra/YTToolkit/ytapi/video.py +10 -10
- webscout/Extra/autocoder/autocoder_utiles.py +1 -1
- webscout/Extra/gguf.py +706 -177
- webscout/Litlogger/formats.py +9 -0
- webscout/Litlogger/handlers.py +18 -0
- webscout/Litlogger/logger.py +43 -1
- webscout/Provider/AISEARCH/genspark_search.py +7 -7
- webscout/Provider/AISEARCH/scira_search.py +3 -2
- webscout/Provider/GeminiProxy.py +140 -0
- webscout/Provider/LambdaChat.py +7 -1
- webscout/Provider/MCPCore.py +78 -75
- webscout/Provider/OPENAI/BLACKBOXAI.py +1046 -1017
- webscout/Provider/OPENAI/GeminiProxy.py +328 -0
- webscout/Provider/OPENAI/Qwen3.py +303 -303
- webscout/Provider/OPENAI/README.md +5 -0
- webscout/Provider/OPENAI/README_AUTOPROXY.md +238 -0
- webscout/Provider/OPENAI/TogetherAI.py +355 -0
- webscout/Provider/OPENAI/__init__.py +16 -1
- webscout/Provider/OPENAI/autoproxy.py +332 -0
- webscout/Provider/OPENAI/base.py +101 -14
- webscout/Provider/OPENAI/chatgpt.py +15 -2
- webscout/Provider/OPENAI/chatgptclone.py +14 -3
- webscout/Provider/OPENAI/deepinfra.py +339 -328
- webscout/Provider/OPENAI/e2b.py +295 -74
- webscout/Provider/OPENAI/mcpcore.py +109 -70
- webscout/Provider/OPENAI/opkfc.py +18 -6
- webscout/Provider/OPENAI/scirachat.py +59 -50
- webscout/Provider/OPENAI/toolbaz.py +2 -10
- webscout/Provider/OPENAI/writecream.py +166 -166
- webscout/Provider/OPENAI/x0gpt.py +367 -367
- webscout/Provider/OPENAI/xenai.py +514 -0
- webscout/Provider/OPENAI/yep.py +389 -383
- webscout/Provider/STT/__init__.py +3 -0
- webscout/Provider/STT/base.py +281 -0
- webscout/Provider/STT/elevenlabs.py +265 -0
- webscout/Provider/TTI/__init__.py +4 -1
- webscout/Provider/TTI/aiarta.py +399 -365
- webscout/Provider/TTI/base.py +74 -2
- webscout/Provider/TTI/bing.py +231 -0
- webscout/Provider/TTI/fastflux.py +63 -30
- webscout/Provider/TTI/gpt1image.py +149 -0
- webscout/Provider/TTI/imagen.py +196 -0
- webscout/Provider/TTI/magicstudio.py +60 -29
- webscout/Provider/TTI/piclumen.py +43 -32
- webscout/Provider/TTI/pixelmuse.py +232 -225
- webscout/Provider/TTI/pollinations.py +43 -32
- webscout/Provider/TTI/together.py +287 -0
- webscout/Provider/TTI/utils.py +2 -1
- webscout/Provider/TTS/README.md +1 -0
- webscout/Provider/TTS/__init__.py +2 -1
- webscout/Provider/TTS/freetts.py +140 -0
- webscout/Provider/TTS/speechma.py +45 -39
- webscout/Provider/TogetherAI.py +366 -0
- webscout/Provider/UNFINISHED/ChutesAI.py +314 -0
- webscout/Provider/UNFINISHED/fetch_together_models.py +95 -0
- webscout/Provider/XenAI.py +324 -0
- webscout/Provider/__init__.py +8 -0
- webscout/Provider/deepseek_assistant.py +378 -0
- webscout/Provider/scira_chat.py +3 -2
- webscout/Provider/toolbaz.py +0 -1
- webscout/auth/__init__.py +44 -0
- webscout/auth/api_key_manager.py +189 -0
- webscout/auth/auth_system.py +100 -0
- webscout/auth/config.py +76 -0
- webscout/auth/database.py +400 -0
- webscout/auth/exceptions.py +67 -0
- webscout/auth/middleware.py +248 -0
- webscout/auth/models.py +130 -0
- webscout/auth/providers.py +257 -0
- webscout/auth/rate_limiter.py +254 -0
- webscout/auth/request_models.py +127 -0
- webscout/auth/request_processing.py +226 -0
- webscout/auth/routes.py +526 -0
- webscout/auth/schemas.py +103 -0
- webscout/auth/server.py +312 -0
- webscout/auth/static/favicon.svg +11 -0
- webscout/auth/swagger_ui.py +203 -0
- webscout/auth/templates/components/authentication.html +237 -0
- webscout/auth/templates/components/base.html +103 -0
- webscout/auth/templates/components/endpoints.html +750 -0
- webscout/auth/templates/components/examples.html +491 -0
- webscout/auth/templates/components/footer.html +75 -0
- webscout/auth/templates/components/header.html +27 -0
- webscout/auth/templates/components/models.html +286 -0
- webscout/auth/templates/components/navigation.html +70 -0
- webscout/auth/templates/static/api.js +455 -0
- webscout/auth/templates/static/icons.js +168 -0
- webscout/auth/templates/static/main.js +784 -0
- webscout/auth/templates/static/particles.js +201 -0
- webscout/auth/templates/static/styles.css +3353 -0
- webscout/auth/templates/static/ui.js +374 -0
- webscout/auth/templates/swagger_ui.html +170 -0
- webscout/client.py +49 -3
- webscout/litagent/Readme.md +12 -3
- webscout/litagent/agent.py +99 -62
- webscout/scout/core/scout.py +104 -26
- webscout/scout/element.py +139 -18
- webscout/swiftcli/core/cli.py +14 -3
- webscout/swiftcli/decorators/output.py +59 -9
- webscout/update_checker.py +31 -49
- webscout/version.py +1 -1
- webscout/webscout_search.py +4 -12
- webscout/webscout_search_async.py +3 -10
- webscout/yep_search.py +2 -11
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/METADATA +41 -11
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/RECORD +116 -68
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/entry_points.txt +1 -1
- webscout/Provider/HF_space/__init__.py +0 -0
- webscout/Provider/HF_space/qwen_qwen2.py +0 -206
- webscout/Provider/OPENAI/api.py +0 -1035
- webscout/Provider/TTI/artbit.py +0 -0
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/WHEEL +0 -0
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/licenses/LICENSE.md +0 -0
- {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# webscout/auth/middleware.py
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from fastapi import Request, HTTPException, status
|
|
9
|
+
from fastapi.security import APIKeyHeader
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
11
|
+
|
|
12
|
+
from .api_key_manager import APIKeyManager
|
|
13
|
+
from .rate_limiter import RateLimiter
|
|
14
|
+
from .models import APIKey
|
|
15
|
+
from .schemas import ErrorResponse, RateLimitStatus
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthMiddleware:
|
|
21
|
+
"""Authentication and rate limiting middleware."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, api_key_manager: APIKeyManager, rate_limiter: RateLimiter, auth_required: bool = True, rate_limit_enabled: bool = True):
|
|
24
|
+
self.api_key_manager = api_key_manager
|
|
25
|
+
self.rate_limiter = rate_limiter
|
|
26
|
+
self.api_key_header = APIKeyHeader(name="Authorization", auto_error=False)
|
|
27
|
+
self.auth_required = auth_required
|
|
28
|
+
self.rate_limit_enabled = rate_limit_enabled
|
|
29
|
+
|
|
30
|
+
# Paths that don't require authentication
|
|
31
|
+
self.public_paths = {
|
|
32
|
+
"/",
|
|
33
|
+
"/docs",
|
|
34
|
+
"/redoc",
|
|
35
|
+
"/openapi.json",
|
|
36
|
+
"/health",
|
|
37
|
+
"/v1/auth/generate-key" # API key generation endpoint
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Paths that require authentication (when auth is enabled)
|
|
41
|
+
self.protected_path_prefixes = [
|
|
42
|
+
"/v1/chat/completions",
|
|
43
|
+
"/v1/images/generations",
|
|
44
|
+
"/v1/models",
|
|
45
|
+
"/v1/TTI/models"
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def is_protected_path(self, path: str) -> bool:
|
|
49
|
+
"""Check if a path requires authentication."""
|
|
50
|
+
if not self.auth_required:
|
|
51
|
+
return False # No authentication required in no-auth mode
|
|
52
|
+
|
|
53
|
+
if path in self.public_paths:
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
return any(path.startswith(prefix) for prefix in self.protected_path_prefixes)
|
|
57
|
+
|
|
58
|
+
def extract_api_key(self, authorization_header: Optional[str]) -> Optional[str]:
|
|
59
|
+
"""Extract API key from Authorization header."""
|
|
60
|
+
if not authorization_header:
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
# Support both "Bearer <key>" and direct key formats
|
|
64
|
+
if authorization_header.startswith("Bearer "):
|
|
65
|
+
return authorization_header[7:]
|
|
66
|
+
elif authorization_header.startswith("ws_"):
|
|
67
|
+
return authorization_header
|
|
68
|
+
else:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
async def authenticate_request(self, request: Request) -> Tuple[bool, Optional[APIKey], Optional[dict]]:
|
|
72
|
+
"""
|
|
73
|
+
Authenticate a request.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Tuple of (is_authenticated, api_key_object, error_response)
|
|
77
|
+
"""
|
|
78
|
+
path = request.url.path
|
|
79
|
+
|
|
80
|
+
# Check if path requires authentication
|
|
81
|
+
if not self.is_protected_path(path):
|
|
82
|
+
return True, None, None
|
|
83
|
+
|
|
84
|
+
# Get authorization header
|
|
85
|
+
auth_header = request.headers.get("authorization")
|
|
86
|
+
api_key = self.extract_api_key(auth_header)
|
|
87
|
+
|
|
88
|
+
if not api_key:
|
|
89
|
+
error_response = {
|
|
90
|
+
"error": "API key required",
|
|
91
|
+
"code": "missing_api_key",
|
|
92
|
+
"details": {
|
|
93
|
+
"message": "Please provide a valid API key in the Authorization header",
|
|
94
|
+
"format": "Authorization: Bearer <your-api-key>"
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return False, None, error_response
|
|
98
|
+
|
|
99
|
+
# Validate API key
|
|
100
|
+
is_valid, api_key_obj, error_msg = await self.api_key_manager.validate_api_key(api_key)
|
|
101
|
+
|
|
102
|
+
if not is_valid:
|
|
103
|
+
error_response = {
|
|
104
|
+
"error": error_msg or "Invalid API key",
|
|
105
|
+
"code": "invalid_api_key",
|
|
106
|
+
"details": {
|
|
107
|
+
"message": "The provided API key is invalid, expired, or inactive"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return False, None, error_response
|
|
111
|
+
|
|
112
|
+
return True, api_key_obj, None
|
|
113
|
+
|
|
114
|
+
async def check_rate_limit(self, api_key: Optional[APIKey], client_ip: str = "unknown") -> Tuple[bool, dict]:
|
|
115
|
+
"""
|
|
116
|
+
Check rate limit for an API key or IP address.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Tuple of (is_allowed, rate_limit_info)
|
|
120
|
+
"""
|
|
121
|
+
if not self.rate_limit_enabled:
|
|
122
|
+
# Rate limiting disabled
|
|
123
|
+
return True, {
|
|
124
|
+
"allowed": True,
|
|
125
|
+
"limit": 999999,
|
|
126
|
+
"remaining": 999999,
|
|
127
|
+
"reset_at": None,
|
|
128
|
+
"retry_after": None
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if api_key:
|
|
132
|
+
return await self.rate_limiter.check_rate_limit(api_key)
|
|
133
|
+
else:
|
|
134
|
+
# No-auth mode: use IP-based rate limiting
|
|
135
|
+
return await self.rate_limiter.check_ip_rate_limit(client_ip)
|
|
136
|
+
|
|
137
|
+
async def process_request(self, request: Request) -> Tuple[bool, Optional[dict], Optional[dict]]:
|
|
138
|
+
"""
|
|
139
|
+
Process a request through authentication and rate limiting.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tuple of (is_allowed, error_response, rate_limit_headers)
|
|
143
|
+
"""
|
|
144
|
+
# Authenticate request
|
|
145
|
+
is_authenticated, api_key_obj, auth_error = await self.authenticate_request(request)
|
|
146
|
+
|
|
147
|
+
if not is_authenticated:
|
|
148
|
+
return False, auth_error, None
|
|
149
|
+
|
|
150
|
+
# If no API key (public endpoint or no-auth mode), handle rate limiting
|
|
151
|
+
if not api_key_obj:
|
|
152
|
+
if not self.auth_required:
|
|
153
|
+
# No-auth mode: still check rate limiting by IP
|
|
154
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
155
|
+
is_allowed, rate_limit_info = await self.check_rate_limit(None, client_ip)
|
|
156
|
+
|
|
157
|
+
if not is_allowed:
|
|
158
|
+
error_response = {
|
|
159
|
+
"error": "Rate limit exceeded",
|
|
160
|
+
"code": "rate_limit_exceeded",
|
|
161
|
+
"details": {
|
|
162
|
+
"message": f"Rate limit of {rate_limit_info['limit']} requests per minute exceeded",
|
|
163
|
+
"retry_after": rate_limit_info["retry_after"],
|
|
164
|
+
"reset_at": rate_limit_info["reset_at"].isoformat() if rate_limit_info["reset_at"] else None
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
rate_limit_headers = {
|
|
168
|
+
"X-RateLimit-Limit": str(rate_limit_info["limit"]),
|
|
169
|
+
"X-RateLimit-Remaining": str(rate_limit_info["remaining"]),
|
|
170
|
+
"Retry-After": str(rate_limit_info["retry_after"])
|
|
171
|
+
}
|
|
172
|
+
if rate_limit_info["reset_at"]:
|
|
173
|
+
rate_limit_headers["X-RateLimit-Reset"] = rate_limit_info["reset_at"].isoformat()
|
|
174
|
+
return False, error_response, rate_limit_headers
|
|
175
|
+
|
|
176
|
+
# Store rate limit info for no-auth mode
|
|
177
|
+
request.state.rate_limit_info = rate_limit_info
|
|
178
|
+
rate_limit_headers = {
|
|
179
|
+
"X-RateLimit-Limit": str(rate_limit_info["limit"]),
|
|
180
|
+
"X-RateLimit-Remaining": str(rate_limit_info["remaining"])
|
|
181
|
+
}
|
|
182
|
+
if rate_limit_info["reset_at"]:
|
|
183
|
+
rate_limit_headers["X-RateLimit-Reset"] = rate_limit_info["reset_at"].isoformat()
|
|
184
|
+
return True, None, rate_limit_headers
|
|
185
|
+
else:
|
|
186
|
+
return True, None, None
|
|
187
|
+
|
|
188
|
+
# Check rate limit for authenticated requests
|
|
189
|
+
client_ip = request.client.host if request.client else "unknown"
|
|
190
|
+
is_allowed, rate_limit_info = await self.check_rate_limit(api_key_obj, client_ip)
|
|
191
|
+
|
|
192
|
+
# Prepare rate limit headers
|
|
193
|
+
rate_limit_headers = {
|
|
194
|
+
"X-RateLimit-Limit": str(rate_limit_info["limit"]),
|
|
195
|
+
"X-RateLimit-Remaining": str(rate_limit_info["remaining"]),
|
|
196
|
+
"X-RateLimit-Reset": rate_limit_info["reset_at"].isoformat()
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if not is_allowed:
|
|
200
|
+
error_response = {
|
|
201
|
+
"error": "Rate limit exceeded",
|
|
202
|
+
"code": "rate_limit_exceeded",
|
|
203
|
+
"details": {
|
|
204
|
+
"message": f"Rate limit of {rate_limit_info['limit']} requests per minute exceeded",
|
|
205
|
+
"retry_after": rate_limit_info["retry_after"],
|
|
206
|
+
"reset_at": rate_limit_info["reset_at"].isoformat()
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
rate_limit_headers["Retry-After"] = str(rate_limit_info["retry_after"])
|
|
210
|
+
return False, error_response, rate_limit_headers
|
|
211
|
+
|
|
212
|
+
# Store API key info in request state for use in endpoints
|
|
213
|
+
request.state.api_key = api_key_obj
|
|
214
|
+
request.state.rate_limit_info = rate_limit_info
|
|
215
|
+
|
|
216
|
+
return True, None, rate_limit_headers
|
|
217
|
+
|
|
218
|
+
def create_error_response(self, error_data: dict, status_code: int = 401, headers: Optional[dict] = None) -> JSONResponse:
|
|
219
|
+
"""Create a standardized error response."""
|
|
220
|
+
response = JSONResponse(
|
|
221
|
+
status_code=status_code,
|
|
222
|
+
content=error_data
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if headers:
|
|
226
|
+
for key, value in headers.items():
|
|
227
|
+
response.headers[key] = value
|
|
228
|
+
|
|
229
|
+
return response
|
|
230
|
+
|
|
231
|
+
async def __call__(self, request: Request, call_next):
|
|
232
|
+
"""Middleware callable for FastAPI."""
|
|
233
|
+
# Process request
|
|
234
|
+
is_allowed, error_response, rate_limit_headers = await self.process_request(request)
|
|
235
|
+
|
|
236
|
+
if not is_allowed:
|
|
237
|
+
status_code = 429 if error_response.get("code") == "rate_limit_exceeded" else 401
|
|
238
|
+
return self.create_error_response(error_response, status_code, rate_limit_headers)
|
|
239
|
+
|
|
240
|
+
# Continue with request
|
|
241
|
+
response = await call_next(request)
|
|
242
|
+
|
|
243
|
+
# Add rate limit headers to response
|
|
244
|
+
if rate_limit_headers:
|
|
245
|
+
for key, value in rate_limit_headers.items():
|
|
246
|
+
response.headers[key] = value
|
|
247
|
+
|
|
248
|
+
return response
|
webscout/auth/models.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# webscout/auth/models.py
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Optional, Dict, Any, List
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import uuid
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class User:
|
|
12
|
+
"""User model for authentication system."""
|
|
13
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
14
|
+
username: str = ""
|
|
15
|
+
telegram_id: int = field(default_factory=lambda: 0) # Required Telegram ID as number only
|
|
16
|
+
|
|
17
|
+
def validate_telegram_id(self) -> None:
|
|
18
|
+
"""Ensure telegram_id is an integer."""
|
|
19
|
+
if not isinstance(self.telegram_id, int):
|
|
20
|
+
raise ValueError("telegram_id must be an integer.")
|
|
21
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
22
|
+
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
23
|
+
is_active: bool = True
|
|
24
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
27
|
+
"""Convert user to dictionary for storage."""
|
|
28
|
+
return {
|
|
29
|
+
"id": self.id,
|
|
30
|
+
"username": self.username,
|
|
31
|
+
"telegram_id": self.telegram_id,
|
|
32
|
+
"created_at": self.created_at.isoformat(),
|
|
33
|
+
"updated_at": self.updated_at.isoformat(),
|
|
34
|
+
"is_active": self.is_active,
|
|
35
|
+
"metadata": self.metadata
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_dict(cls, data: Dict[str, Any]) -> "User":
|
|
40
|
+
"""Create user from dictionary."""
|
|
41
|
+
return cls(
|
|
42
|
+
id=data.get("id", str(uuid.uuid4())),
|
|
43
|
+
username=data.get("username", ""),
|
|
44
|
+
telegram_id=int(data.get("telegram_id", 0)),
|
|
45
|
+
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
|
46
|
+
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
|
|
47
|
+
is_active=data.get("is_active", True),
|
|
48
|
+
metadata=data.get("metadata", {})
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class APIKey:
|
|
54
|
+
"""API Key model for authentication system."""
|
|
55
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
56
|
+
key: str = ""
|
|
57
|
+
user_id: str = ""
|
|
58
|
+
name: Optional[str] = None
|
|
59
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
60
|
+
last_used_at: Optional[datetime] = None
|
|
61
|
+
expires_at: Optional[datetime] = None
|
|
62
|
+
is_active: bool = True
|
|
63
|
+
rate_limit: int = 10 # requests per minute
|
|
64
|
+
usage_count: int = 0
|
|
65
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
68
|
+
"""Convert API key to dictionary for storage."""
|
|
69
|
+
return {
|
|
70
|
+
"id": self.id,
|
|
71
|
+
"key": self.key,
|
|
72
|
+
"user_id": self.user_id,
|
|
73
|
+
"name": self.name,
|
|
74
|
+
"created_at": self.created_at.isoformat(),
|
|
75
|
+
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
|
|
76
|
+
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
|
77
|
+
"is_active": self.is_active,
|
|
78
|
+
"rate_limit": self.rate_limit,
|
|
79
|
+
"usage_count": self.usage_count,
|
|
80
|
+
"metadata": self.metadata
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_dict(cls, data: Dict[str, Any]) -> "APIKey":
|
|
85
|
+
"""Create API key from dictionary."""
|
|
86
|
+
return cls(
|
|
87
|
+
id=data.get("id", str(uuid.uuid4())),
|
|
88
|
+
key=data.get("key", ""),
|
|
89
|
+
user_id=data.get("user_id", ""),
|
|
90
|
+
name=data.get("name"),
|
|
91
|
+
created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())),
|
|
92
|
+
last_used_at=datetime.fromisoformat(data["last_used_at"]) if data.get("last_used_at") else None,
|
|
93
|
+
expires_at=datetime.fromisoformat(data["expires_at"]) if data.get("expires_at") else None,
|
|
94
|
+
is_active=data.get("is_active", True),
|
|
95
|
+
rate_limit=data.get("rate_limit", 10),
|
|
96
|
+
usage_count=data.get("usage_count", 0),
|
|
97
|
+
metadata=data.get("metadata", {})
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def is_expired(self) -> bool:
|
|
101
|
+
"""Check if API key is expired."""
|
|
102
|
+
if not self.expires_at:
|
|
103
|
+
return False
|
|
104
|
+
return datetime.now(timezone.utc) > self.expires_at
|
|
105
|
+
|
|
106
|
+
def is_valid(self) -> bool:
|
|
107
|
+
"""Check if API key is valid (active and not expired)."""
|
|
108
|
+
return self.is_active and not self.is_expired()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class RateLimitEntry:
|
|
113
|
+
"""Rate limit tracking entry."""
|
|
114
|
+
api_key_id: str
|
|
115
|
+
requests: List[datetime] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
118
|
+
"""Convert to dictionary for storage."""
|
|
119
|
+
return {
|
|
120
|
+
"api_key_id": self.api_key_id,
|
|
121
|
+
"requests": [req.isoformat() for req in self.requests]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_dict(cls, data: Dict[str, Any]) -> "RateLimitEntry":
|
|
126
|
+
"""Create from dictionary."""
|
|
127
|
+
return cls(
|
|
128
|
+
api_key_id=data.get("api_key_id", ""),
|
|
129
|
+
requests=[datetime.fromisoformat(req) for req in data.get("requests", [])]
|
|
130
|
+
)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provider management and initialization for the Webscout API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import inspect
|
|
7
|
+
from typing import Any, Dict, Tuple
|
|
8
|
+
from starlette.status import HTTP_404_NOT_FOUND, HTTP_500_INTERNAL_SERVER_ERROR
|
|
9
|
+
|
|
10
|
+
from webscout.Litlogger import Logger, LogLevel, LogFormat, ConsoleHandler
|
|
11
|
+
from .config import AppConfig
|
|
12
|
+
from .exceptions import APIError
|
|
13
|
+
|
|
14
|
+
# Setup logger
|
|
15
|
+
logger = Logger(
|
|
16
|
+
name="webscout.api",
|
|
17
|
+
level=LogLevel.INFO,
|
|
18
|
+
handlers=[ConsoleHandler(stream=sys.stdout)],
|
|
19
|
+
fmt=LogFormat.DEFAULT
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Cache for provider instances to avoid reinitialization on every request
|
|
23
|
+
provider_instances: Dict[str, Any] = {}
|
|
24
|
+
tti_provider_instances: Dict[str, Any] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def initialize_provider_map() -> None:
|
|
28
|
+
"""Initialize the provider map by discovering available providers."""
|
|
29
|
+
logger.info("Initializing provider map...")
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from webscout.Provider.OPENAI.base import OpenAICompatibleProvider
|
|
33
|
+
module = sys.modules["webscout.Provider.OPENAI"]
|
|
34
|
+
|
|
35
|
+
provider_count = 0
|
|
36
|
+
model_count = 0
|
|
37
|
+
|
|
38
|
+
for name, obj in inspect.getmembers(module):
|
|
39
|
+
if (
|
|
40
|
+
inspect.isclass(obj)
|
|
41
|
+
and issubclass(obj, OpenAICompatibleProvider)
|
|
42
|
+
and obj.__name__ != "OpenAICompatibleProvider"
|
|
43
|
+
):
|
|
44
|
+
provider_name = obj.__name__
|
|
45
|
+
AppConfig.provider_map[provider_name] = obj
|
|
46
|
+
provider_count += 1
|
|
47
|
+
|
|
48
|
+
# Register available models for this provider
|
|
49
|
+
if hasattr(obj, "AVAILABLE_MODELS") and isinstance(
|
|
50
|
+
obj.AVAILABLE_MODELS, (list, tuple, set)
|
|
51
|
+
):
|
|
52
|
+
for model in obj.AVAILABLE_MODELS:
|
|
53
|
+
if model and isinstance(model, str):
|
|
54
|
+
model_key = f"{provider_name}/{model}"
|
|
55
|
+
AppConfig.provider_map[model_key] = obj
|
|
56
|
+
model_count += 1
|
|
57
|
+
|
|
58
|
+
# Fallback to ChatGPT if no providers found
|
|
59
|
+
if not AppConfig.provider_map:
|
|
60
|
+
logger.warning("No providers found, using ChatGPT fallback")
|
|
61
|
+
try:
|
|
62
|
+
from webscout.Provider.OPENAI.chatgpt import ChatGPT
|
|
63
|
+
fallback_models = ["gpt-4", "gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"]
|
|
64
|
+
|
|
65
|
+
AppConfig.provider_map["ChatGPT"] = ChatGPT
|
|
66
|
+
|
|
67
|
+
for model in fallback_models:
|
|
68
|
+
model_key = f"ChatGPT/{model}"
|
|
69
|
+
AppConfig.provider_map[model_key] = ChatGPT
|
|
70
|
+
|
|
71
|
+
AppConfig.default_provider = "ChatGPT"
|
|
72
|
+
provider_count = 1
|
|
73
|
+
model_count = len(fallback_models)
|
|
74
|
+
except ImportError as e:
|
|
75
|
+
logger.error(f"Failed to import ChatGPT fallback: {e}")
|
|
76
|
+
raise APIError("No providers available", HTTP_500_INTERNAL_SERVER_ERROR)
|
|
77
|
+
|
|
78
|
+
logger.info(f"Initialized {provider_count} providers with {model_count} models")
|
|
79
|
+
|
|
80
|
+
except Exception as e:
|
|
81
|
+
logger.error(f"Failed to initialize provider map: {e}")
|
|
82
|
+
raise APIError(f"Provider initialization failed: {e}", HTTP_500_INTERNAL_SERVER_ERROR)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def initialize_tti_provider_map() -> None:
|
|
86
|
+
"""Initialize the TTI provider map by discovering available TTI providers."""
|
|
87
|
+
logger.info("Initializing TTI provider map...")
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
import webscout.Provider.TTI as tti_module
|
|
91
|
+
from webscout.Provider.TTI.base import TTICompatibleProvider
|
|
92
|
+
|
|
93
|
+
provider_count = 0
|
|
94
|
+
model_count = 0
|
|
95
|
+
|
|
96
|
+
for name, obj in inspect.getmembers(tti_module):
|
|
97
|
+
if (
|
|
98
|
+
inspect.isclass(obj)
|
|
99
|
+
and issubclass(obj, TTICompatibleProvider)
|
|
100
|
+
and obj.__name__ != "TTICompatibleProvider"
|
|
101
|
+
and obj.__name__ != "BaseImages"
|
|
102
|
+
):
|
|
103
|
+
provider_name = obj.__name__
|
|
104
|
+
AppConfig.tti_provider_map[provider_name] = obj
|
|
105
|
+
provider_count += 1
|
|
106
|
+
|
|
107
|
+
# Register available models for this TTI provider
|
|
108
|
+
if hasattr(obj, "AVAILABLE_MODELS") and isinstance(
|
|
109
|
+
obj.AVAILABLE_MODELS, (list, tuple, set)
|
|
110
|
+
):
|
|
111
|
+
for model in obj.AVAILABLE_MODELS:
|
|
112
|
+
if model and isinstance(model, str):
|
|
113
|
+
model_key = f"{provider_name}/{model}"
|
|
114
|
+
AppConfig.tti_provider_map[model_key] = obj
|
|
115
|
+
model_count += 1
|
|
116
|
+
|
|
117
|
+
# Fallback to PollinationsAI if no TTI providers found
|
|
118
|
+
if not AppConfig.tti_provider_map:
|
|
119
|
+
logger.warning("No TTI providers found, using PollinationsAI fallback")
|
|
120
|
+
try:
|
|
121
|
+
from webscout.Provider.TTI.pollinations import PollinationsAI
|
|
122
|
+
fallback_models = ["flux", "turbo", "gptimage"]
|
|
123
|
+
|
|
124
|
+
AppConfig.tti_provider_map["PollinationsAI"] = PollinationsAI
|
|
125
|
+
|
|
126
|
+
for model in fallback_models:
|
|
127
|
+
model_key = f"PollinationsAI/{model}"
|
|
128
|
+
AppConfig.tti_provider_map[model_key] = PollinationsAI
|
|
129
|
+
|
|
130
|
+
AppConfig.default_tti_provider = "PollinationsAI"
|
|
131
|
+
provider_count = 1
|
|
132
|
+
model_count = len(fallback_models)
|
|
133
|
+
except ImportError as e:
|
|
134
|
+
logger.error(f"Failed to import PollinationsAI fallback: {e}")
|
|
135
|
+
raise APIError("No TTI providers available", HTTP_500_INTERNAL_SERVER_ERROR)
|
|
136
|
+
|
|
137
|
+
logger.info(f"Initialized {provider_count} TTI providers with {model_count} models")
|
|
138
|
+
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.error(f"Failed to initialize TTI provider map: {e}")
|
|
141
|
+
raise APIError(f"TTI Provider initialization failed: {e}", HTTP_500_INTERNAL_SERVER_ERROR)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def resolve_provider_and_model(model_identifier: str) -> Tuple[Any, str]:
|
|
145
|
+
"""Resolve provider class and model name from model identifier."""
|
|
146
|
+
provider_class = None
|
|
147
|
+
model_name = None
|
|
148
|
+
|
|
149
|
+
# Check for explicit provider/model syntax
|
|
150
|
+
if model_identifier in AppConfig.provider_map and "/" in model_identifier:
|
|
151
|
+
provider_class = AppConfig.provider_map[model_identifier]
|
|
152
|
+
_, model_name = model_identifier.split("/", 1)
|
|
153
|
+
elif "/" in model_identifier:
|
|
154
|
+
provider_name, model_name = model_identifier.split("/", 1)
|
|
155
|
+
provider_class = AppConfig.provider_map.get(provider_name)
|
|
156
|
+
else:
|
|
157
|
+
provider_class = AppConfig.provider_map.get(AppConfig.default_provider)
|
|
158
|
+
model_name = model_identifier
|
|
159
|
+
|
|
160
|
+
if not provider_class:
|
|
161
|
+
available_providers = list(set(v.__name__ for v in AppConfig.provider_map.values()))
|
|
162
|
+
raise APIError(
|
|
163
|
+
f"Provider for model '{model_identifier}' not found. Available providers: {available_providers}",
|
|
164
|
+
HTTP_404_NOT_FOUND,
|
|
165
|
+
"model_not_found",
|
|
166
|
+
param="model"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Validate model availability
|
|
170
|
+
if hasattr(provider_class, "AVAILABLE_MODELS") and model_name is not None:
|
|
171
|
+
available = getattr(provider_class, "AVAILABLE_MODELS", None)
|
|
172
|
+
# If it's a property, get from instance
|
|
173
|
+
if isinstance(available, property):
|
|
174
|
+
try:
|
|
175
|
+
available = getattr(provider_class(), "AVAILABLE_MODELS", [])
|
|
176
|
+
except Exception:
|
|
177
|
+
available = []
|
|
178
|
+
# If still not iterable, fallback to empty list
|
|
179
|
+
if not isinstance(available, (list, tuple, set)):
|
|
180
|
+
available = list(available) if hasattr(available, "__iter__") and not isinstance(available, str) else []
|
|
181
|
+
if available and model_name not in available:
|
|
182
|
+
raise APIError(
|
|
183
|
+
f"Model '{model_name}' not supported by provider '{provider_class.__name__}'. Available models: {available}",
|
|
184
|
+
HTTP_404_NOT_FOUND,
|
|
185
|
+
"model_not_found",
|
|
186
|
+
param="model"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return provider_class, model_name
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def resolve_tti_provider_and_model(model_identifier: str) -> Tuple[Any, str]:
|
|
193
|
+
"""Resolve TTI provider class and model name from model identifier."""
|
|
194
|
+
provider_class = None
|
|
195
|
+
model_name = None
|
|
196
|
+
|
|
197
|
+
# Check for explicit provider/model syntax
|
|
198
|
+
if model_identifier in AppConfig.tti_provider_map and "/" in model_identifier:
|
|
199
|
+
provider_class = AppConfig.tti_provider_map[model_identifier]
|
|
200
|
+
_, model_name = model_identifier.split("/", 1)
|
|
201
|
+
elif "/" in model_identifier:
|
|
202
|
+
provider_name, model_name = model_identifier.split("/", 1)
|
|
203
|
+
provider_class = AppConfig.tti_provider_map.get(provider_name)
|
|
204
|
+
else:
|
|
205
|
+
provider_class = AppConfig.tti_provider_map.get(AppConfig.default_tti_provider)
|
|
206
|
+
model_name = model_identifier
|
|
207
|
+
|
|
208
|
+
if not provider_class:
|
|
209
|
+
available_providers = list(set(v.__name__ for v in AppConfig.tti_provider_map.values()))
|
|
210
|
+
raise APIError(
|
|
211
|
+
f"TTI Provider for model '{model_identifier}' not found. Available TTI providers: {available_providers}",
|
|
212
|
+
HTTP_404_NOT_FOUND,
|
|
213
|
+
"model_not_found",
|
|
214
|
+
param="model"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Validate model availability
|
|
218
|
+
if hasattr(provider_class, "AVAILABLE_MODELS") and model_name is not None:
|
|
219
|
+
available = getattr(provider_class, "AVAILABLE_MODELS", None)
|
|
220
|
+
# If it's a property, get from instance
|
|
221
|
+
if isinstance(available, property):
|
|
222
|
+
try:
|
|
223
|
+
available = getattr(provider_class(), "AVAILABLE_MODELS", [])
|
|
224
|
+
except Exception:
|
|
225
|
+
available = []
|
|
226
|
+
# If still not iterable, fallback to empty list
|
|
227
|
+
if not isinstance(available, (list, tuple, set)):
|
|
228
|
+
available = list(available) if hasattr(available, "__iter__") and not isinstance(available, str) else []
|
|
229
|
+
if available and model_name not in available:
|
|
230
|
+
raise APIError(
|
|
231
|
+
f"Model '{model_name}' not supported by TTI provider '{provider_class.__name__}'. Available models: {available}",
|
|
232
|
+
HTTP_404_NOT_FOUND,
|
|
233
|
+
"model_not_found",
|
|
234
|
+
param="model"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return provider_class, model_name
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def get_provider_instance(provider_class: Any):
|
|
241
|
+
"""Return a cached instance of the provider, creating it if necessary."""
|
|
242
|
+
key = provider_class.__name__
|
|
243
|
+
instance = provider_instances.get(key)
|
|
244
|
+
if instance is None:
|
|
245
|
+
instance = provider_class()
|
|
246
|
+
provider_instances[key] = instance
|
|
247
|
+
return instance
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def get_tti_provider_instance(provider_class: Any):
|
|
251
|
+
"""Return a cached instance of the TTI provider, creating it if needed."""
|
|
252
|
+
key = provider_class.__name__
|
|
253
|
+
instance = tti_provider_instances.get(key)
|
|
254
|
+
if instance is None:
|
|
255
|
+
instance = provider_class()
|
|
256
|
+
tti_provider_instances[key] = instance
|
|
257
|
+
return instance
|