webscout 8.3.1__py3-none-any.whl → 8.3.3__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/AIutel.py +180 -78
- webscout/Bing_search.py +417 -0
- webscout/Extra/gguf.py +706 -177
- webscout/Provider/AISEARCH/__init__.py +1 -0
- webscout/Provider/AISEARCH/genspark_search.py +7 -7
- webscout/Provider/AISEARCH/stellar_search.py +132 -0
- webscout/Provider/ExaChat.py +84 -58
- webscout/Provider/GeminiProxy.py +140 -0
- webscout/Provider/HeckAI.py +85 -80
- webscout/Provider/Jadve.py +56 -50
- webscout/Provider/MCPCore.py +78 -75
- webscout/Provider/MiniMax.py +207 -0
- webscout/Provider/Nemotron.py +41 -13
- webscout/Provider/Netwrck.py +34 -51
- webscout/Provider/OPENAI/BLACKBOXAI.py +0 -4
- webscout/Provider/OPENAI/GeminiProxy.py +328 -0
- webscout/Provider/OPENAI/MiniMax.py +298 -0
- webscout/Provider/OPENAI/README.md +32 -29
- webscout/Provider/OPENAI/README_AUTOPROXY.md +238 -0
- webscout/Provider/OPENAI/TogetherAI.py +4 -17
- webscout/Provider/OPENAI/__init__.py +17 -1
- webscout/Provider/OPENAI/autoproxy.py +1067 -39
- webscout/Provider/OPENAI/base.py +17 -76
- webscout/Provider/OPENAI/deepinfra.py +42 -108
- webscout/Provider/OPENAI/e2b.py +0 -1
- webscout/Provider/OPENAI/flowith.py +179 -166
- webscout/Provider/OPENAI/friendli.py +233 -0
- webscout/Provider/OPENAI/mcpcore.py +109 -70
- webscout/Provider/OPENAI/monochat.py +329 -0
- webscout/Provider/OPENAI/pydantic_imports.py +1 -172
- webscout/Provider/OPENAI/scirachat.py +59 -51
- webscout/Provider/OPENAI/toolbaz.py +3 -9
- webscout/Provider/OPENAI/typegpt.py +1 -1
- webscout/Provider/OPENAI/utils.py +19 -42
- webscout/Provider/OPENAI/x0gpt.py +14 -2
- webscout/Provider/OPENAI/xenai.py +514 -0
- webscout/Provider/OPENAI/yep.py +8 -2
- webscout/Provider/OpenGPT.py +54 -32
- webscout/Provider/PI.py +58 -84
- webscout/Provider/StandardInput.py +32 -13
- webscout/Provider/TTI/README.md +9 -9
- webscout/Provider/TTI/__init__.py +3 -1
- webscout/Provider/TTI/aiarta.py +92 -78
- webscout/Provider/TTI/bing.py +231 -0
- webscout/Provider/TTI/infip.py +212 -0
- webscout/Provider/TTI/monochat.py +220 -0
- webscout/Provider/TTS/speechma.py +45 -39
- webscout/Provider/TeachAnything.py +11 -3
- webscout/Provider/TextPollinationsAI.py +78 -70
- webscout/Provider/TogetherAI.py +350 -0
- webscout/Provider/Venice.py +37 -46
- webscout/Provider/VercelAI.py +27 -24
- webscout/Provider/WiseCat.py +35 -35
- webscout/Provider/WrDoChat.py +22 -26
- webscout/Provider/WritingMate.py +26 -22
- webscout/Provider/XenAI.py +324 -0
- webscout/Provider/__init__.py +10 -5
- webscout/Provider/deepseek_assistant.py +378 -0
- webscout/Provider/granite.py +48 -57
- webscout/Provider/koala.py +51 -39
- webscout/Provider/learnfastai.py +49 -64
- webscout/Provider/llmchat.py +79 -93
- webscout/Provider/llmchatco.py +63 -78
- webscout/Provider/multichat.py +51 -40
- webscout/Provider/oivscode.py +1 -1
- webscout/Provider/scira_chat.py +159 -96
- webscout/Provider/scnet.py +13 -13
- webscout/Provider/searchchat.py +13 -13
- webscout/Provider/sonus.py +12 -11
- webscout/Provider/toolbaz.py +25 -8
- webscout/Provider/turboseek.py +41 -42
- webscout/Provider/typefully.py +27 -12
- webscout/Provider/typegpt.py +41 -46
- webscout/Provider/uncovr.py +55 -90
- webscout/Provider/x0gpt.py +33 -17
- webscout/Provider/yep.py +79 -96
- webscout/auth/__init__.py +55 -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 +279 -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 +550 -0
- webscout/auth/schemas.py +103 -0
- webscout/auth/server.py +367 -0
- webscout/client.py +121 -70
- webscout/litagent/Readme.md +68 -55
- webscout/litagent/agent.py +99 -9
- 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.1.dist-info → webscout-8.3.3.dist-info}/METADATA +141 -99
- {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/RECORD +109 -83
- {webscout-8.3.1.dist-info → webscout-8.3.3.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 -1320
- webscout/Provider/TTI/fastflux.py +0 -233
- webscout/Provider/Writecream.py +0 -246
- {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/WHEEL +0 -0
- {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/licenses/LICENSE.md +0 -0
- {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# webscout/auth/rate_limiter.py
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime, timezone, timedelta
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from .models import APIKey, RateLimitEntry
|
|
9
|
+
from .database import DatabaseManager
|
|
10
|
+
from .config import AppConfig
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RateLimiter:
|
|
16
|
+
"""Rate limiter for API requests."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, database_manager: DatabaseManager):
|
|
19
|
+
self.db = database_manager
|
|
20
|
+
self.default_rate_limit = 10 # requests per minute
|
|
21
|
+
self.window_size = 60 # 1 minute in seconds
|
|
22
|
+
|
|
23
|
+
async def check_rate_limit(self, api_key: APIKey) -> Tuple[bool, dict]:
|
|
24
|
+
"""
|
|
25
|
+
Check if a request is allowed under the rate limit.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Tuple of (is_allowed, rate_limit_info)
|
|
29
|
+
"""
|
|
30
|
+
now = datetime.now(timezone.utc)
|
|
31
|
+
window_start = now - timedelta(seconds=self.window_size)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
# Get or create rate limit entry
|
|
35
|
+
entry = await self.db.get_rate_limit_entry(api_key.id)
|
|
36
|
+
|
|
37
|
+
if not entry:
|
|
38
|
+
entry = RateLimitEntry(api_key_id=api_key.id, requests=[])
|
|
39
|
+
|
|
40
|
+
# Clean old requests outside the window
|
|
41
|
+
entry.requests = [req for req in entry.requests if req > window_start]
|
|
42
|
+
|
|
43
|
+
# Check if limit is exceeded
|
|
44
|
+
current_count = len(entry.requests)
|
|
45
|
+
limit = api_key.rate_limit or self.default_rate_limit
|
|
46
|
+
|
|
47
|
+
if current_count >= limit:
|
|
48
|
+
# Rate limit exceeded
|
|
49
|
+
oldest_request = min(entry.requests) if entry.requests else now
|
|
50
|
+
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
51
|
+
retry_after = int((reset_at - now).total_seconds())
|
|
52
|
+
|
|
53
|
+
rate_limit_info = {
|
|
54
|
+
"allowed": False,
|
|
55
|
+
"limit": limit,
|
|
56
|
+
"remaining": 0,
|
|
57
|
+
"reset_at": reset_at,
|
|
58
|
+
"retry_after": max(retry_after, 1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
logger.warning(f"Rate limit exceeded for API key {api_key.id}: {current_count}/{limit}")
|
|
62
|
+
return False, rate_limit_info
|
|
63
|
+
|
|
64
|
+
# Add current request
|
|
65
|
+
entry.requests.append(now)
|
|
66
|
+
|
|
67
|
+
# Update database
|
|
68
|
+
await self.db.update_rate_limit_entry(entry)
|
|
69
|
+
|
|
70
|
+
# Calculate next reset time
|
|
71
|
+
if entry.requests:
|
|
72
|
+
oldest_request = min(entry.requests)
|
|
73
|
+
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
74
|
+
else:
|
|
75
|
+
reset_at = now + timedelta(seconds=self.window_size)
|
|
76
|
+
|
|
77
|
+
rate_limit_info = {
|
|
78
|
+
"allowed": True,
|
|
79
|
+
"limit": limit,
|
|
80
|
+
"remaining": limit - len(entry.requests),
|
|
81
|
+
"reset_at": reset_at,
|
|
82
|
+
"retry_after": None
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return True, rate_limit_info
|
|
86
|
+
|
|
87
|
+
except Exception as e:
|
|
88
|
+
logger.error(f"Error checking rate limit for API key {api_key.id}: {e}")
|
|
89
|
+
# In case of error, allow the request but log the issue
|
|
90
|
+
rate_limit_info = {
|
|
91
|
+
"allowed": True,
|
|
92
|
+
"limit": api_key.rate_limit or self.default_rate_limit,
|
|
93
|
+
"remaining": api_key.rate_limit or self.default_rate_limit,
|
|
94
|
+
"reset_at": now + timedelta(seconds=self.window_size),
|
|
95
|
+
"retry_after": None
|
|
96
|
+
}
|
|
97
|
+
return True, rate_limit_info
|
|
98
|
+
|
|
99
|
+
async def reset_rate_limit(self, api_key_id: str) -> bool:
|
|
100
|
+
"""Reset rate limit for an API key (admin function)."""
|
|
101
|
+
try:
|
|
102
|
+
entry = RateLimitEntry(api_key_id=api_key_id, requests=[])
|
|
103
|
+
await self.db.update_rate_limit_entry(entry)
|
|
104
|
+
logger.info(f"Reset rate limit for API key {api_key_id}")
|
|
105
|
+
return True
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Error resetting rate limit for API key {api_key_id}: {e}")
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
async def get_rate_limit_status(self, api_key: APIKey) -> dict:
|
|
111
|
+
"""Get current rate limit status without making a request."""
|
|
112
|
+
now = datetime.now(timezone.utc)
|
|
113
|
+
window_start = now - timedelta(seconds=self.window_size)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
entry = await self.db.get_rate_limit_entry(api_key.id)
|
|
117
|
+
|
|
118
|
+
if not entry:
|
|
119
|
+
entry = RateLimitEntry(api_key_id=api_key.id, requests=[])
|
|
120
|
+
|
|
121
|
+
# Clean old requests
|
|
122
|
+
entry.requests = [req for req in entry.requests if req > window_start]
|
|
123
|
+
|
|
124
|
+
limit = api_key.rate_limit or self.default_rate_limit
|
|
125
|
+
current_count = len(entry.requests)
|
|
126
|
+
|
|
127
|
+
# Calculate reset time
|
|
128
|
+
if entry.requests:
|
|
129
|
+
oldest_request = min(entry.requests)
|
|
130
|
+
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
131
|
+
else:
|
|
132
|
+
reset_at = now + timedelta(seconds=self.window_size)
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
"limit": limit,
|
|
136
|
+
"remaining": max(0, limit - current_count),
|
|
137
|
+
"reset_at": reset_at,
|
|
138
|
+
"window_size": self.window_size
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
except Exception as e:
|
|
142
|
+
logger.error(f"Error getting rate limit status for API key {api_key.id}: {e}")
|
|
143
|
+
return {
|
|
144
|
+
"limit": api_key.rate_limit or self.default_rate_limit,
|
|
145
|
+
"remaining": api_key.rate_limit or self.default_rate_limit,
|
|
146
|
+
"reset_at": now + timedelta(seconds=self.window_size),
|
|
147
|
+
"window_size": self.window_size
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async def cleanup_old_entries(self) -> int:
|
|
151
|
+
"""Clean up old rate limit entries (maintenance function)."""
|
|
152
|
+
# Remove requests older than the window_size for all rate limit entries
|
|
153
|
+
try:
|
|
154
|
+
# Try to get all rate limit entries from the database
|
|
155
|
+
if hasattr(self.db, 'get_all_rate_limit_entries'):
|
|
156
|
+
entries = await self.db.get_all_rate_limit_entries()
|
|
157
|
+
else:
|
|
158
|
+
logger.warning("Database does not support get_all_rate_limit_entries; cleanup skipped.")
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
now = datetime.now(timezone.utc)
|
|
162
|
+
window_start = now - timedelta(seconds=self.window_size)
|
|
163
|
+
cleaned = 0
|
|
164
|
+
|
|
165
|
+
for entry in entries:
|
|
166
|
+
old_count = len(entry.requests)
|
|
167
|
+
entry.requests = [req for req in entry.requests if req > window_start]
|
|
168
|
+
if len(entry.requests) < old_count:
|
|
169
|
+
await self.db.update_rate_limit_entry(entry)
|
|
170
|
+
cleaned += 1
|
|
171
|
+
logger.info(f"Cleaned up {cleaned} old rate limit entries.")
|
|
172
|
+
return cleaned
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.error(f"Error cleaning up old rate limit entries: {e}")
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
async def check_ip_rate_limit(self, client_ip: str) -> Tuple[bool, dict]:
|
|
178
|
+
"""
|
|
179
|
+
Check rate limit for an IP address (used in no-auth mode).
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Tuple of (is_allowed, rate_limit_info)
|
|
183
|
+
"""
|
|
184
|
+
now = datetime.now(timezone.utc)
|
|
185
|
+
window_start = now - timedelta(seconds=self.window_size)
|
|
186
|
+
|
|
187
|
+
# Create a pseudo API key for IP-based rate limiting
|
|
188
|
+
ip_key_id = f"ip_{client_ip}"
|
|
189
|
+
limit = AppConfig.default_rate_limit
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
# Get or create rate limit entry for IP
|
|
193
|
+
entry = await self.db.get_rate_limit_entry(ip_key_id)
|
|
194
|
+
|
|
195
|
+
if not entry:
|
|
196
|
+
entry = RateLimitEntry(api_key_id=ip_key_id, requests=[])
|
|
197
|
+
|
|
198
|
+
# Clean old requests outside the window
|
|
199
|
+
entry.requests = [req for req in entry.requests if req > window_start]
|
|
200
|
+
|
|
201
|
+
# Check if limit is exceeded
|
|
202
|
+
current_count = len(entry.requests)
|
|
203
|
+
|
|
204
|
+
if current_count >= limit:
|
|
205
|
+
# Rate limit exceeded
|
|
206
|
+
oldest_request = min(entry.requests) if entry.requests else now
|
|
207
|
+
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
208
|
+
retry_after = int((reset_at - now).total_seconds())
|
|
209
|
+
|
|
210
|
+
rate_limit_info = {
|
|
211
|
+
"allowed": False,
|
|
212
|
+
"limit": limit,
|
|
213
|
+
"remaining": 0,
|
|
214
|
+
"reset_at": reset_at,
|
|
215
|
+
"retry_after": max(retry_after, 1)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
logger.warning(f"Rate limit exceeded for IP {client_ip}: {current_count}/{limit}")
|
|
219
|
+
return False, rate_limit_info
|
|
220
|
+
|
|
221
|
+
# Add current request
|
|
222
|
+
entry.requests.append(now)
|
|
223
|
+
|
|
224
|
+
# Update database
|
|
225
|
+
await self.db.update_rate_limit_entry(entry)
|
|
226
|
+
|
|
227
|
+
# Calculate next reset time
|
|
228
|
+
if entry.requests:
|
|
229
|
+
oldest_request = min(entry.requests)
|
|
230
|
+
reset_at = oldest_request + timedelta(seconds=self.window_size)
|
|
231
|
+
else:
|
|
232
|
+
reset_at = now + timedelta(seconds=self.window_size)
|
|
233
|
+
|
|
234
|
+
rate_limit_info = {
|
|
235
|
+
"allowed": True,
|
|
236
|
+
"limit": limit,
|
|
237
|
+
"remaining": limit - len(entry.requests),
|
|
238
|
+
"reset_at": reset_at,
|
|
239
|
+
"retry_after": None
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return True, rate_limit_info
|
|
243
|
+
|
|
244
|
+
except Exception as e:
|
|
245
|
+
logger.error(f"Error checking rate limit for IP {client_ip}: {e}")
|
|
246
|
+
# In case of error, allow the request but log the issue
|
|
247
|
+
rate_limit_info = {
|
|
248
|
+
"allowed": True,
|
|
249
|
+
"limit": limit,
|
|
250
|
+
"remaining": limit,
|
|
251
|
+
"reset_at": now + timedelta(seconds=self.window_size),
|
|
252
|
+
"retry_after": None
|
|
253
|
+
}
|
|
254
|
+
return True, rate_limit_info
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic models for API requests and responses.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Dict, Optional, Union, Any, Literal
|
|
6
|
+
from webscout.Provider.OPENAI.pydantic_imports import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Define Pydantic models for multimodal content parts, aligning with OpenAI's API
|
|
10
|
+
class TextPart(BaseModel):
|
|
11
|
+
"""Text content part for multimodal messages."""
|
|
12
|
+
type: Literal["text"]
|
|
13
|
+
text: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImageURL(BaseModel):
|
|
17
|
+
"""Image URL configuration for multimodal messages."""
|
|
18
|
+
url: str # Can be http(s) or data URI
|
|
19
|
+
detail: Optional[Literal["auto", "low", "high"]] = Field(
|
|
20
|
+
"auto",
|
|
21
|
+
description="Specifies the detail level of the image."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ImagePart(BaseModel):
|
|
26
|
+
"""Image content part for multimodal messages."""
|
|
27
|
+
type: Literal["image_url"]
|
|
28
|
+
image_url: ImageURL
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
MessageContentParts = Union[TextPart, ImagePart]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Message(BaseModel):
|
|
35
|
+
"""Chat message model compatible with OpenAI API."""
|
|
36
|
+
role: Literal["system", "user", "assistant", "function", "tool"]
|
|
37
|
+
content: Optional[Union[str, List[MessageContentParts]]] = Field(
|
|
38
|
+
None,
|
|
39
|
+
description="The content of the message. Can be a string, a list of content parts (for multimodal), or null."
|
|
40
|
+
)
|
|
41
|
+
name: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ChatCompletionRequest(BaseModel):
|
|
45
|
+
"""Request model for chat completions."""
|
|
46
|
+
model: str = Field(..., description="ID of the model to use. See the model endpoint for the available models.")
|
|
47
|
+
messages: List[Message] = Field(..., description="A list of messages comprising the conversation so far.")
|
|
48
|
+
temperature: Optional[float] = Field(None, description="What sampling temperature to use, between 0 and 2.")
|
|
49
|
+
top_p: Optional[float] = Field(None, description="An alternative to sampling with temperature, called nucleus sampling.")
|
|
50
|
+
n: Optional[int] = Field(1, description="How many chat completion choices to generate for each input message.")
|
|
51
|
+
stream: Optional[bool] = Field(False, description="If set, partial message deltas will be sent, like in ChatGPT.")
|
|
52
|
+
max_tokens: Optional[int] = Field(None, description="The maximum number of tokens to generate in the chat completion.")
|
|
53
|
+
presence_penalty: Optional[float] = Field(None, description="Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far.")
|
|
54
|
+
frequency_penalty: Optional[float] = Field(None, description="Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far.")
|
|
55
|
+
logit_bias: Optional[Dict[str, float]] = Field(None, description="Modify the likelihood of specified tokens appearing in the completion.")
|
|
56
|
+
user: Optional[str] = Field(None, description="A unique identifier representing your end-user.")
|
|
57
|
+
stop: Optional[Union[str, List[str]]] = Field(None, description="Up to 4 sequences where the API will stop generating further tokens.")
|
|
58
|
+
|
|
59
|
+
class Config:
|
|
60
|
+
extra = "ignore"
|
|
61
|
+
schema_extra = {
|
|
62
|
+
"example": {
|
|
63
|
+
"model": "Cloudflare/@cf/meta/llama-4-scout-17b-16e-instruct",
|
|
64
|
+
"messages": [
|
|
65
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
66
|
+
{"role": "user", "content": "Hello, how are you?"}
|
|
67
|
+
],
|
|
68
|
+
"temperature": 0.7,
|
|
69
|
+
"max_tokens": 150,
|
|
70
|
+
"stream": False
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ImageGenerationRequest(BaseModel):
|
|
76
|
+
"""Request model for OpenAI-compatible image generation endpoint."""
|
|
77
|
+
prompt: str = Field(..., description="A text description of the desired image(s). The maximum length is 1000 characters.")
|
|
78
|
+
model: str = Field(..., description="The model to use for image generation.")
|
|
79
|
+
n: Optional[int] = Field(1, description="The number of images to generate. Must be between 1 and 10.")
|
|
80
|
+
size: Optional[str] = Field("1024x1024", description="The size of the generated images. Must be one of: '256x256', '512x512', or '1024x1024'.")
|
|
81
|
+
response_format: Optional[Literal["url", "b64_json"]] = Field("url", description="The format in which the generated images are returned.")
|
|
82
|
+
user: Optional[str] = Field(None, description="A unique identifier representing your end-user.")
|
|
83
|
+
style: Optional[str] = Field(None, description="Optional style for the image (provider/model-specific).")
|
|
84
|
+
aspect_ratio: Optional[str] = Field(None, description="Optional aspect ratio for the image (provider/model-specific).")
|
|
85
|
+
timeout: Optional[int] = Field(None, description="Optional timeout for the image generation request in seconds.")
|
|
86
|
+
image_format: Optional[str] = Field(None, description="Optional image format (e.g., 'png', 'jpeg').")
|
|
87
|
+
seed: Optional[int] = Field(None, description="Optional random seed for reproducibility.")
|
|
88
|
+
|
|
89
|
+
class Config:
|
|
90
|
+
extra = "ignore"
|
|
91
|
+
schema_extra = {
|
|
92
|
+
"example": {
|
|
93
|
+
"prompt": "A futuristic cityscape at sunset, digital art",
|
|
94
|
+
"model": "PollinationsAI/turbo",
|
|
95
|
+
"n": 1,
|
|
96
|
+
"size": "1024x1024",
|
|
97
|
+
"response_format": "url",
|
|
98
|
+
"user": "user-1234"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ModelInfo(BaseModel):
|
|
104
|
+
"""Model information for the models endpoint."""
|
|
105
|
+
id: str
|
|
106
|
+
object: str = "model"
|
|
107
|
+
created: int
|
|
108
|
+
owned_by: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ModelListResponse(BaseModel):
|
|
112
|
+
"""Response model for the models list endpoint."""
|
|
113
|
+
object: str = "list"
|
|
114
|
+
data: List[ModelInfo]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ErrorDetail(BaseModel):
|
|
118
|
+
"""Error detail structure compatible with OpenAI API."""
|
|
119
|
+
message: str
|
|
120
|
+
type: str = "server_error"
|
|
121
|
+
param: Optional[str] = None
|
|
122
|
+
code: Optional[str] = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ErrorResponse(BaseModel):
|
|
126
|
+
"""Error response structure compatible with OpenAI API."""
|
|
127
|
+
error: ErrorDetail
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Request processing utilities for the Webscout API.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import List, Dict, Any
|
|
9
|
+
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, HTTP_500_INTERNAL_SERVER_ERROR
|
|
10
|
+
from fastapi.responses import StreamingResponse
|
|
11
|
+
|
|
12
|
+
from webscout.Provider.OPENAI.utils import ChatCompletion, Choice, ChatCompletionMessage, CompletionUsage
|
|
13
|
+
from webscout.Litlogger import Logger, LogLevel, LogFormat, ConsoleHandler
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from .request_models import Message, ChatCompletionRequest
|
|
17
|
+
from .exceptions import APIError, clean_text
|
|
18
|
+
|
|
19
|
+
# Setup logger
|
|
20
|
+
logger = Logger(
|
|
21
|
+
name="webscout.api",
|
|
22
|
+
level=LogLevel.INFO,
|
|
23
|
+
handlers=[ConsoleHandler(stream=sys.stdout)],
|
|
24
|
+
fmt=LogFormat.DEFAULT
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def process_messages(messages: List[Message]) -> List[Dict[str, Any]]:
|
|
29
|
+
"""Process and validate chat messages."""
|
|
30
|
+
processed_messages = []
|
|
31
|
+
|
|
32
|
+
for i, msg_in in enumerate(messages):
|
|
33
|
+
try:
|
|
34
|
+
message_dict_out = {"role": msg_in.role}
|
|
35
|
+
|
|
36
|
+
if msg_in.content is None:
|
|
37
|
+
message_dict_out["content"] = None
|
|
38
|
+
elif isinstance(msg_in.content, str):
|
|
39
|
+
message_dict_out["content"] = msg_in.content
|
|
40
|
+
else: # List[MessageContentParts]
|
|
41
|
+
message_dict_out["content"] = [
|
|
42
|
+
part.model_dump(exclude_none=True) for part in msg_in.content
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
if msg_in.name:
|
|
46
|
+
message_dict_out["name"] = msg_in.name
|
|
47
|
+
|
|
48
|
+
processed_messages.append(message_dict_out)
|
|
49
|
+
|
|
50
|
+
except Exception as e:
|
|
51
|
+
raise APIError(
|
|
52
|
+
f"Invalid message at index {i}: {str(e)}",
|
|
53
|
+
HTTP_422_UNPROCESSABLE_ENTITY,
|
|
54
|
+
"invalid_request_error",
|
|
55
|
+
param=f"messages[{i}]"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return processed_messages
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def prepare_provider_params(chat_request: ChatCompletionRequest, model_name: str,
|
|
62
|
+
processed_messages: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
63
|
+
"""Prepare parameters for the provider."""
|
|
64
|
+
params = {
|
|
65
|
+
"model": model_name,
|
|
66
|
+
"messages": processed_messages,
|
|
67
|
+
"stream": chat_request.stream,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Add optional parameters if present
|
|
71
|
+
optional_params = [
|
|
72
|
+
"temperature", "max_tokens", "top_p", "presence_penalty",
|
|
73
|
+
"frequency_penalty", "stop", "user"
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
for param in optional_params:
|
|
77
|
+
value = getattr(chat_request, param, None)
|
|
78
|
+
if value is not None:
|
|
79
|
+
params[param] = value
|
|
80
|
+
|
|
81
|
+
return params
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def handle_streaming_response(provider: Any, params: Dict[str, Any], request_id: str) -> StreamingResponse:
|
|
85
|
+
"""Handle streaming chat completion response."""
|
|
86
|
+
async def streaming():
|
|
87
|
+
try:
|
|
88
|
+
logger.debug(f"Starting streaming response for request {request_id}")
|
|
89
|
+
completion_stream = provider.chat.completions.create(**params)
|
|
90
|
+
|
|
91
|
+
# Check if it's iterable (generator, iterator, or other iterable types)
|
|
92
|
+
if hasattr(completion_stream, '__iter__') and not isinstance(completion_stream, (str, bytes, dict)):
|
|
93
|
+
try:
|
|
94
|
+
for chunk in completion_stream:
|
|
95
|
+
# Standardize chunk format before sending
|
|
96
|
+
if hasattr(chunk, 'model_dump'): # Pydantic v2
|
|
97
|
+
chunk_data = chunk.model_dump(exclude_none=True)
|
|
98
|
+
elif hasattr(chunk, 'dict'): # Pydantic v1
|
|
99
|
+
chunk_data = chunk.dict(exclude_none=True)
|
|
100
|
+
elif isinstance(chunk, dict):
|
|
101
|
+
chunk_data = chunk
|
|
102
|
+
else: # Fallback for unknown chunk types
|
|
103
|
+
chunk_data = chunk
|
|
104
|
+
|
|
105
|
+
# Clean text content in the chunk to remove control characters
|
|
106
|
+
if isinstance(chunk_data, dict) and 'choices' in chunk_data:
|
|
107
|
+
for choice in chunk_data.get('choices', []):
|
|
108
|
+
if isinstance(choice, dict):
|
|
109
|
+
# Handle delta for streaming
|
|
110
|
+
if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
|
|
111
|
+
choice['delta']['content'] = clean_text(choice['delta']['content'])
|
|
112
|
+
# Handle message for non-streaming
|
|
113
|
+
elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
|
|
114
|
+
choice['message']['content'] = clean_text(choice['message']['content'])
|
|
115
|
+
|
|
116
|
+
yield f"data: {json.dumps(chunk_data, ensure_ascii=False)}\n\n"
|
|
117
|
+
except TypeError as te:
|
|
118
|
+
logger.error(f"Error iterating over completion_stream: {te}")
|
|
119
|
+
# Fall back to treating as non-generator response
|
|
120
|
+
if hasattr(completion_stream, 'model_dump'):
|
|
121
|
+
response_data = completion_stream.model_dump(exclude_none=True)
|
|
122
|
+
elif hasattr(completion_stream, 'dict'):
|
|
123
|
+
response_data = completion_stream.dict(exclude_none=True)
|
|
124
|
+
else:
|
|
125
|
+
response_data = completion_stream
|
|
126
|
+
|
|
127
|
+
# Clean text content in the response
|
|
128
|
+
if isinstance(response_data, dict) and 'choices' in response_data:
|
|
129
|
+
for choice in response_data.get('choices', []):
|
|
130
|
+
if isinstance(choice, dict):
|
|
131
|
+
if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
|
|
132
|
+
choice['delta']['content'] = clean_text(choice['delta']['content'])
|
|
133
|
+
elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
|
|
134
|
+
choice['message']['content'] = clean_text(choice['message']['content'])
|
|
135
|
+
|
|
136
|
+
yield f"data: {json.dumps(response_data, ensure_ascii=False)}\n\n"
|
|
137
|
+
else: # Non-generator response
|
|
138
|
+
if hasattr(completion_stream, 'model_dump'):
|
|
139
|
+
response_data = completion_stream.model_dump(exclude_none=True)
|
|
140
|
+
elif hasattr(completion_stream, 'dict'):
|
|
141
|
+
response_data = completion_stream.dict(exclude_none=True)
|
|
142
|
+
else:
|
|
143
|
+
response_data = completion_stream
|
|
144
|
+
|
|
145
|
+
# Clean text content in the response
|
|
146
|
+
if isinstance(response_data, dict) and 'choices' in response_data:
|
|
147
|
+
for choice in response_data.get('choices', []):
|
|
148
|
+
if isinstance(choice, dict):
|
|
149
|
+
if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
|
|
150
|
+
choice['delta']['content'] = clean_text(choice['delta']['content'])
|
|
151
|
+
elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
|
|
152
|
+
choice['message']['content'] = clean_text(choice['message']['content'])
|
|
153
|
+
|
|
154
|
+
yield f"data: {json.dumps(response_data, ensure_ascii=False)}\n\n"
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.error(f"Error in streaming response for request {request_id}: {e}")
|
|
158
|
+
error_message = clean_text(str(e))
|
|
159
|
+
error_data = {
|
|
160
|
+
"error": {
|
|
161
|
+
"message": error_message,
|
|
162
|
+
"type": "server_error",
|
|
163
|
+
"code": "streaming_error"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
|
|
167
|
+
finally:
|
|
168
|
+
yield "data: [DONE]\n\n"
|
|
169
|
+
return StreamingResponse(streaming(), media_type="text/event-stream")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def handle_non_streaming_response(provider: Any, params: Dict[str, Any],
|
|
173
|
+
request_id: str, start_time: float) -> Dict[str, Any]:
|
|
174
|
+
"""Handle non-streaming chat completion response."""
|
|
175
|
+
try:
|
|
176
|
+
logger.debug(f"Starting non-streaming response for request {request_id}")
|
|
177
|
+
completion = provider.chat.completions.create(**params)
|
|
178
|
+
|
|
179
|
+
if completion is None:
|
|
180
|
+
# Return a valid OpenAI-compatible error response
|
|
181
|
+
return ChatCompletion(
|
|
182
|
+
id=request_id,
|
|
183
|
+
created=int(time.time()),
|
|
184
|
+
model=params.get("model", "unknown"),
|
|
185
|
+
choices=[Choice(
|
|
186
|
+
index=0,
|
|
187
|
+
message=ChatCompletionMessage(role="assistant", content="No response generated."),
|
|
188
|
+
finish_reason="error"
|
|
189
|
+
)],
|
|
190
|
+
usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
|
|
191
|
+
).model_dump(exclude_none=True)
|
|
192
|
+
|
|
193
|
+
# Standardize response format
|
|
194
|
+
if hasattr(completion, "model_dump"): # Pydantic v2
|
|
195
|
+
response_data = completion.model_dump(exclude_none=True)
|
|
196
|
+
elif hasattr(completion, "dict"): # Pydantic v1
|
|
197
|
+
response_data = completion.dict(exclude_none=True)
|
|
198
|
+
elif isinstance(completion, dict):
|
|
199
|
+
response_data = completion
|
|
200
|
+
else:
|
|
201
|
+
raise APIError(
|
|
202
|
+
"Invalid response format from provider",
|
|
203
|
+
HTTP_500_INTERNAL_SERVER_ERROR,
|
|
204
|
+
"provider_error"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Clean text content in the response to remove control characters
|
|
208
|
+
if isinstance(response_data, dict) and 'choices' in response_data:
|
|
209
|
+
for choice in response_data.get('choices', []):
|
|
210
|
+
if isinstance(choice, dict) and 'message' in choice:
|
|
211
|
+
if isinstance(choice['message'], dict) and 'content' in choice['message']:
|
|
212
|
+
choice['message']['content'] = clean_text(choice['message']['content'])
|
|
213
|
+
|
|
214
|
+
elapsed = time.time() - start_time
|
|
215
|
+
logger.info(f"Completed non-streaming request {request_id} in {elapsed:.2f}s")
|
|
216
|
+
|
|
217
|
+
return response_data
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.error(f"Error in non-streaming response for request {request_id}: {e}")
|
|
221
|
+
error_message = clean_text(str(e))
|
|
222
|
+
raise APIError(
|
|
223
|
+
f"Provider error: {error_message}",
|
|
224
|
+
HTTP_500_INTERNAL_SERVER_ERROR,
|
|
225
|
+
"provider_error"
|
|
226
|
+
)
|