webscout 8.3.1__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.

Files changed (77) hide show
  1. webscout/AIutel.py +46 -53
  2. webscout/Bing_search.py +418 -0
  3. webscout/Extra/gguf.py +706 -177
  4. webscout/Provider/AISEARCH/genspark_search.py +7 -7
  5. webscout/Provider/GeminiProxy.py +140 -0
  6. webscout/Provider/MCPCore.py +78 -75
  7. webscout/Provider/OPENAI/BLACKBOXAI.py +1 -4
  8. webscout/Provider/OPENAI/GeminiProxy.py +328 -0
  9. webscout/Provider/OPENAI/README.md +2 -0
  10. webscout/Provider/OPENAI/README_AUTOPROXY.md +238 -0
  11. webscout/Provider/OPENAI/__init__.py +15 -1
  12. webscout/Provider/OPENAI/autoproxy.py +332 -39
  13. webscout/Provider/OPENAI/base.py +15 -5
  14. webscout/Provider/OPENAI/e2b.py +0 -1
  15. webscout/Provider/OPENAI/mcpcore.py +109 -70
  16. webscout/Provider/OPENAI/scirachat.py +59 -51
  17. webscout/Provider/OPENAI/toolbaz.py +2 -9
  18. webscout/Provider/OPENAI/xenai.py +514 -0
  19. webscout/Provider/OPENAI/yep.py +8 -2
  20. webscout/Provider/TTI/__init__.py +1 -0
  21. webscout/Provider/TTI/bing.py +231 -0
  22. webscout/Provider/TTS/speechma.py +45 -39
  23. webscout/Provider/TogetherAI.py +366 -0
  24. webscout/Provider/XenAI.py +324 -0
  25. webscout/Provider/__init__.py +8 -3
  26. webscout/Provider/deepseek_assistant.py +378 -0
  27. webscout/auth/__init__.py +44 -0
  28. webscout/auth/api_key_manager.py +189 -0
  29. webscout/auth/auth_system.py +100 -0
  30. webscout/auth/config.py +76 -0
  31. webscout/auth/database.py +400 -0
  32. webscout/auth/exceptions.py +67 -0
  33. webscout/auth/middleware.py +248 -0
  34. webscout/auth/models.py +130 -0
  35. webscout/auth/providers.py +257 -0
  36. webscout/auth/rate_limiter.py +254 -0
  37. webscout/auth/request_models.py +127 -0
  38. webscout/auth/request_processing.py +226 -0
  39. webscout/auth/routes.py +526 -0
  40. webscout/auth/schemas.py +103 -0
  41. webscout/auth/server.py +312 -0
  42. webscout/auth/static/favicon.svg +11 -0
  43. webscout/auth/swagger_ui.py +203 -0
  44. webscout/auth/templates/components/authentication.html +237 -0
  45. webscout/auth/templates/components/base.html +103 -0
  46. webscout/auth/templates/components/endpoints.html +750 -0
  47. webscout/auth/templates/components/examples.html +491 -0
  48. webscout/auth/templates/components/footer.html +75 -0
  49. webscout/auth/templates/components/header.html +27 -0
  50. webscout/auth/templates/components/models.html +286 -0
  51. webscout/auth/templates/components/navigation.html +70 -0
  52. webscout/auth/templates/static/api.js +455 -0
  53. webscout/auth/templates/static/icons.js +168 -0
  54. webscout/auth/templates/static/main.js +784 -0
  55. webscout/auth/templates/static/particles.js +201 -0
  56. webscout/auth/templates/static/styles.css +3353 -0
  57. webscout/auth/templates/static/ui.js +374 -0
  58. webscout/auth/templates/swagger_ui.html +170 -0
  59. webscout/client.py +49 -3
  60. webscout/scout/core/scout.py +104 -26
  61. webscout/scout/element.py +139 -18
  62. webscout/swiftcli/core/cli.py +14 -3
  63. webscout/swiftcli/decorators/output.py +59 -9
  64. webscout/update_checker.py +31 -49
  65. webscout/version.py +1 -1
  66. webscout/webscout_search.py +4 -12
  67. webscout/webscout_search_async.py +3 -10
  68. webscout/yep_search.py +2 -11
  69. {webscout-8.3.1.dist-info → webscout-8.3.2.dist-info}/METADATA +41 -11
  70. {webscout-8.3.1.dist-info → webscout-8.3.2.dist-info}/RECORD +74 -36
  71. {webscout-8.3.1.dist-info → webscout-8.3.2.dist-info}/entry_points.txt +1 -1
  72. webscout/Provider/HF_space/__init__.py +0 -0
  73. webscout/Provider/HF_space/qwen_qwen2.py +0 -206
  74. webscout/Provider/OPENAI/api.py +0 -1320
  75. {webscout-8.3.1.dist-info → webscout-8.3.2.dist-info}/WHEEL +0 -0
  76. {webscout-8.3.1.dist-info → webscout-8.3.2.dist-info}/licenses/LICENSE.md +0 -0
  77. {webscout-8.3.1.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
@@ -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