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.

Files changed (120) hide show
  1. webscout/AIauto.py +4 -4
  2. webscout/AIbase.py +61 -1
  3. webscout/AIutel.py +46 -53
  4. webscout/Bing_search.py +418 -0
  5. webscout/Extra/YTToolkit/ytapi/patterns.py +45 -45
  6. webscout/Extra/YTToolkit/ytapi/stream.py +1 -1
  7. webscout/Extra/YTToolkit/ytapi/video.py +10 -10
  8. webscout/Extra/autocoder/autocoder_utiles.py +1 -1
  9. webscout/Extra/gguf.py +706 -177
  10. webscout/Litlogger/formats.py +9 -0
  11. webscout/Litlogger/handlers.py +18 -0
  12. webscout/Litlogger/logger.py +43 -1
  13. webscout/Provider/AISEARCH/genspark_search.py +7 -7
  14. webscout/Provider/AISEARCH/scira_search.py +3 -2
  15. webscout/Provider/GeminiProxy.py +140 -0
  16. webscout/Provider/LambdaChat.py +7 -1
  17. webscout/Provider/MCPCore.py +78 -75
  18. webscout/Provider/OPENAI/BLACKBOXAI.py +1046 -1017
  19. webscout/Provider/OPENAI/GeminiProxy.py +328 -0
  20. webscout/Provider/OPENAI/Qwen3.py +303 -303
  21. webscout/Provider/OPENAI/README.md +5 -0
  22. webscout/Provider/OPENAI/README_AUTOPROXY.md +238 -0
  23. webscout/Provider/OPENAI/TogetherAI.py +355 -0
  24. webscout/Provider/OPENAI/__init__.py +16 -1
  25. webscout/Provider/OPENAI/autoproxy.py +332 -0
  26. webscout/Provider/OPENAI/base.py +101 -14
  27. webscout/Provider/OPENAI/chatgpt.py +15 -2
  28. webscout/Provider/OPENAI/chatgptclone.py +14 -3
  29. webscout/Provider/OPENAI/deepinfra.py +339 -328
  30. webscout/Provider/OPENAI/e2b.py +295 -74
  31. webscout/Provider/OPENAI/mcpcore.py +109 -70
  32. webscout/Provider/OPENAI/opkfc.py +18 -6
  33. webscout/Provider/OPENAI/scirachat.py +59 -50
  34. webscout/Provider/OPENAI/toolbaz.py +2 -10
  35. webscout/Provider/OPENAI/writecream.py +166 -166
  36. webscout/Provider/OPENAI/x0gpt.py +367 -367
  37. webscout/Provider/OPENAI/xenai.py +514 -0
  38. webscout/Provider/OPENAI/yep.py +389 -383
  39. webscout/Provider/STT/__init__.py +3 -0
  40. webscout/Provider/STT/base.py +281 -0
  41. webscout/Provider/STT/elevenlabs.py +265 -0
  42. webscout/Provider/TTI/__init__.py +4 -1
  43. webscout/Provider/TTI/aiarta.py +399 -365
  44. webscout/Provider/TTI/base.py +74 -2
  45. webscout/Provider/TTI/bing.py +231 -0
  46. webscout/Provider/TTI/fastflux.py +63 -30
  47. webscout/Provider/TTI/gpt1image.py +149 -0
  48. webscout/Provider/TTI/imagen.py +196 -0
  49. webscout/Provider/TTI/magicstudio.py +60 -29
  50. webscout/Provider/TTI/piclumen.py +43 -32
  51. webscout/Provider/TTI/pixelmuse.py +232 -225
  52. webscout/Provider/TTI/pollinations.py +43 -32
  53. webscout/Provider/TTI/together.py +287 -0
  54. webscout/Provider/TTI/utils.py +2 -1
  55. webscout/Provider/TTS/README.md +1 -0
  56. webscout/Provider/TTS/__init__.py +2 -1
  57. webscout/Provider/TTS/freetts.py +140 -0
  58. webscout/Provider/TTS/speechma.py +45 -39
  59. webscout/Provider/TogetherAI.py +366 -0
  60. webscout/Provider/UNFINISHED/ChutesAI.py +314 -0
  61. webscout/Provider/UNFINISHED/fetch_together_models.py +95 -0
  62. webscout/Provider/XenAI.py +324 -0
  63. webscout/Provider/__init__.py +8 -0
  64. webscout/Provider/deepseek_assistant.py +378 -0
  65. webscout/Provider/scira_chat.py +3 -2
  66. webscout/Provider/toolbaz.py +0 -1
  67. webscout/auth/__init__.py +44 -0
  68. webscout/auth/api_key_manager.py +189 -0
  69. webscout/auth/auth_system.py +100 -0
  70. webscout/auth/config.py +76 -0
  71. webscout/auth/database.py +400 -0
  72. webscout/auth/exceptions.py +67 -0
  73. webscout/auth/middleware.py +248 -0
  74. webscout/auth/models.py +130 -0
  75. webscout/auth/providers.py +257 -0
  76. webscout/auth/rate_limiter.py +254 -0
  77. webscout/auth/request_models.py +127 -0
  78. webscout/auth/request_processing.py +226 -0
  79. webscout/auth/routes.py +526 -0
  80. webscout/auth/schemas.py +103 -0
  81. webscout/auth/server.py +312 -0
  82. webscout/auth/static/favicon.svg +11 -0
  83. webscout/auth/swagger_ui.py +203 -0
  84. webscout/auth/templates/components/authentication.html +237 -0
  85. webscout/auth/templates/components/base.html +103 -0
  86. webscout/auth/templates/components/endpoints.html +750 -0
  87. webscout/auth/templates/components/examples.html +491 -0
  88. webscout/auth/templates/components/footer.html +75 -0
  89. webscout/auth/templates/components/header.html +27 -0
  90. webscout/auth/templates/components/models.html +286 -0
  91. webscout/auth/templates/components/navigation.html +70 -0
  92. webscout/auth/templates/static/api.js +455 -0
  93. webscout/auth/templates/static/icons.js +168 -0
  94. webscout/auth/templates/static/main.js +784 -0
  95. webscout/auth/templates/static/particles.js +201 -0
  96. webscout/auth/templates/static/styles.css +3353 -0
  97. webscout/auth/templates/static/ui.js +374 -0
  98. webscout/auth/templates/swagger_ui.html +170 -0
  99. webscout/client.py +49 -3
  100. webscout/litagent/Readme.md +12 -3
  101. webscout/litagent/agent.py +99 -62
  102. webscout/scout/core/scout.py +104 -26
  103. webscout/scout/element.py +139 -18
  104. webscout/swiftcli/core/cli.py +14 -3
  105. webscout/swiftcli/decorators/output.py +59 -9
  106. webscout/update_checker.py +31 -49
  107. webscout/version.py +1 -1
  108. webscout/webscout_search.py +4 -12
  109. webscout/webscout_search_async.py +3 -10
  110. webscout/yep_search.py +2 -11
  111. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/METADATA +41 -11
  112. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/RECORD +116 -68
  113. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/entry_points.txt +1 -1
  114. webscout/Provider/HF_space/__init__.py +0 -0
  115. webscout/Provider/HF_space/qwen_qwen2.py +0 -206
  116. webscout/Provider/OPENAI/api.py +0 -1035
  117. webscout/Provider/TTI/artbit.py +0 -0
  118. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/WHEEL +0 -0
  119. {webscout-8.3.dist-info → webscout-8.3.2.dist-info}/licenses/LICENSE.md +0 -0
  120. {webscout-8.3.dist-info → webscout-8.3.2.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
+ )