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.

Files changed (114) hide show
  1. webscout/AIutel.py +180 -78
  2. webscout/Bing_search.py +417 -0
  3. webscout/Extra/gguf.py +706 -177
  4. webscout/Provider/AISEARCH/__init__.py +1 -0
  5. webscout/Provider/AISEARCH/genspark_search.py +7 -7
  6. webscout/Provider/AISEARCH/stellar_search.py +132 -0
  7. webscout/Provider/ExaChat.py +84 -58
  8. webscout/Provider/GeminiProxy.py +140 -0
  9. webscout/Provider/HeckAI.py +85 -80
  10. webscout/Provider/Jadve.py +56 -50
  11. webscout/Provider/MCPCore.py +78 -75
  12. webscout/Provider/MiniMax.py +207 -0
  13. webscout/Provider/Nemotron.py +41 -13
  14. webscout/Provider/Netwrck.py +34 -51
  15. webscout/Provider/OPENAI/BLACKBOXAI.py +0 -4
  16. webscout/Provider/OPENAI/GeminiProxy.py +328 -0
  17. webscout/Provider/OPENAI/MiniMax.py +298 -0
  18. webscout/Provider/OPENAI/README.md +32 -29
  19. webscout/Provider/OPENAI/README_AUTOPROXY.md +238 -0
  20. webscout/Provider/OPENAI/TogetherAI.py +4 -17
  21. webscout/Provider/OPENAI/__init__.py +17 -1
  22. webscout/Provider/OPENAI/autoproxy.py +1067 -39
  23. webscout/Provider/OPENAI/base.py +17 -76
  24. webscout/Provider/OPENAI/deepinfra.py +42 -108
  25. webscout/Provider/OPENAI/e2b.py +0 -1
  26. webscout/Provider/OPENAI/flowith.py +179 -166
  27. webscout/Provider/OPENAI/friendli.py +233 -0
  28. webscout/Provider/OPENAI/mcpcore.py +109 -70
  29. webscout/Provider/OPENAI/monochat.py +329 -0
  30. webscout/Provider/OPENAI/pydantic_imports.py +1 -172
  31. webscout/Provider/OPENAI/scirachat.py +59 -51
  32. webscout/Provider/OPENAI/toolbaz.py +3 -9
  33. webscout/Provider/OPENAI/typegpt.py +1 -1
  34. webscout/Provider/OPENAI/utils.py +19 -42
  35. webscout/Provider/OPENAI/x0gpt.py +14 -2
  36. webscout/Provider/OPENAI/xenai.py +514 -0
  37. webscout/Provider/OPENAI/yep.py +8 -2
  38. webscout/Provider/OpenGPT.py +54 -32
  39. webscout/Provider/PI.py +58 -84
  40. webscout/Provider/StandardInput.py +32 -13
  41. webscout/Provider/TTI/README.md +9 -9
  42. webscout/Provider/TTI/__init__.py +3 -1
  43. webscout/Provider/TTI/aiarta.py +92 -78
  44. webscout/Provider/TTI/bing.py +231 -0
  45. webscout/Provider/TTI/infip.py +212 -0
  46. webscout/Provider/TTI/monochat.py +220 -0
  47. webscout/Provider/TTS/speechma.py +45 -39
  48. webscout/Provider/TeachAnything.py +11 -3
  49. webscout/Provider/TextPollinationsAI.py +78 -70
  50. webscout/Provider/TogetherAI.py +350 -0
  51. webscout/Provider/Venice.py +37 -46
  52. webscout/Provider/VercelAI.py +27 -24
  53. webscout/Provider/WiseCat.py +35 -35
  54. webscout/Provider/WrDoChat.py +22 -26
  55. webscout/Provider/WritingMate.py +26 -22
  56. webscout/Provider/XenAI.py +324 -0
  57. webscout/Provider/__init__.py +10 -5
  58. webscout/Provider/deepseek_assistant.py +378 -0
  59. webscout/Provider/granite.py +48 -57
  60. webscout/Provider/koala.py +51 -39
  61. webscout/Provider/learnfastai.py +49 -64
  62. webscout/Provider/llmchat.py +79 -93
  63. webscout/Provider/llmchatco.py +63 -78
  64. webscout/Provider/multichat.py +51 -40
  65. webscout/Provider/oivscode.py +1 -1
  66. webscout/Provider/scira_chat.py +159 -96
  67. webscout/Provider/scnet.py +13 -13
  68. webscout/Provider/searchchat.py +13 -13
  69. webscout/Provider/sonus.py +12 -11
  70. webscout/Provider/toolbaz.py +25 -8
  71. webscout/Provider/turboseek.py +41 -42
  72. webscout/Provider/typefully.py +27 -12
  73. webscout/Provider/typegpt.py +41 -46
  74. webscout/Provider/uncovr.py +55 -90
  75. webscout/Provider/x0gpt.py +33 -17
  76. webscout/Provider/yep.py +79 -96
  77. webscout/auth/__init__.py +55 -0
  78. webscout/auth/api_key_manager.py +189 -0
  79. webscout/auth/auth_system.py +100 -0
  80. webscout/auth/config.py +76 -0
  81. webscout/auth/database.py +400 -0
  82. webscout/auth/exceptions.py +67 -0
  83. webscout/auth/middleware.py +248 -0
  84. webscout/auth/models.py +130 -0
  85. webscout/auth/providers.py +279 -0
  86. webscout/auth/rate_limiter.py +254 -0
  87. webscout/auth/request_models.py +127 -0
  88. webscout/auth/request_processing.py +226 -0
  89. webscout/auth/routes.py +550 -0
  90. webscout/auth/schemas.py +103 -0
  91. webscout/auth/server.py +367 -0
  92. webscout/client.py +121 -70
  93. webscout/litagent/Readme.md +68 -55
  94. webscout/litagent/agent.py +99 -9
  95. webscout/scout/core/scout.py +104 -26
  96. webscout/scout/element.py +139 -18
  97. webscout/swiftcli/core/cli.py +14 -3
  98. webscout/swiftcli/decorators/output.py +59 -9
  99. webscout/update_checker.py +31 -49
  100. webscout/version.py +1 -1
  101. webscout/webscout_search.py +4 -12
  102. webscout/webscout_search_async.py +3 -10
  103. webscout/yep_search.py +2 -11
  104. {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/METADATA +141 -99
  105. {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/RECORD +109 -83
  106. {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/entry_points.txt +1 -1
  107. webscout/Provider/HF_space/__init__.py +0 -0
  108. webscout/Provider/HF_space/qwen_qwen2.py +0 -206
  109. webscout/Provider/OPENAI/api.py +0 -1320
  110. webscout/Provider/TTI/fastflux.py +0 -233
  111. webscout/Provider/Writecream.py +0 -246
  112. {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/WHEEL +0 -0
  113. {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/licenses/LICENSE.md +0 -0
  114. {webscout-8.3.1.dist-info → webscout-8.3.3.dist-info}/top_level.txt +0 -0
@@ -1,1320 +0,0 @@
1
- """
2
- Webscout OpenAI-Compatible API Server
3
-
4
- A FastAPI-based server that provides OpenAI-compatible endpoints for various LLM providers.
5
- Supports streaming and non-streaming chat completions with comprehensive error handling,
6
- authentication, and provider management.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import json
12
- import os
13
- import secrets
14
- import sys
15
- import time
16
- import uuid
17
- import inspect
18
- import re
19
- import codecs
20
- from typing import List, Dict, Optional, Union, Any, Generator, Callable
21
- import types
22
-
23
- from webscout.Litlogger import Logger, LogLevel, LogFormat, ConsoleHandler
24
- import uvicorn
25
- from fastapi import FastAPI, Response, Request, Body
26
- from fastapi.middleware.cors import CORSMiddleware
27
- from fastapi.responses import StreamingResponse, RedirectResponse, JSONResponse
28
- from fastapi.openapi.utils import get_openapi
29
- from fastapi.routing import APIRoute
30
- from fastapi.exceptions import RequestValidationError
31
- from fastapi.security import APIKeyHeader
32
- from starlette.exceptions import HTTPException as StarletteHTTPException
33
-
34
- def clean_text(text):
35
- """Clean text by removing null bytes and control characters except newlines and tabs."""
36
- if not isinstance(text, str):
37
- return text
38
-
39
- # Remove null bytes
40
- text = text.replace('\x00', '')
41
-
42
- # Keep newlines, tabs, and other printable characters, remove other control chars
43
- # This regex matches control characters except \n, \r, \t
44
- return re.sub(r'[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
45
- from starlette.status import (
46
- HTTP_422_UNPROCESSABLE_ENTITY,
47
- HTTP_404_NOT_FOUND,
48
- HTTP_401_UNAUTHORIZED,
49
- HTTP_403_FORBIDDEN,
50
- HTTP_500_INTERNAL_SERVER_ERROR,
51
- )
52
-
53
- from webscout.Provider.OPENAI.pydantic_imports import BaseModel, Field
54
- from typing import Literal
55
-
56
- # Import provider classes from the OPENAI directory
57
- from webscout.Provider.OPENAI import *
58
- from webscout.Provider.OPENAI.utils import (
59
- ChatCompletion, Choice, ChatCompletionMessage, CompletionUsage
60
- )
61
- from webscout.Provider.TTI import *
62
- from webscout.Provider.TTI.utils import ImageData, ImageResponse
63
- from webscout.Provider.TTI.base import TTICompatibleProvider
64
-
65
-
66
- # Configuration constants
67
- DEFAULT_PORT = 8000
68
- DEFAULT_HOST = "0.0.0.0"
69
- API_VERSION = "v1"
70
-
71
- # Setup Litlogger
72
- logger = Logger(
73
- name="webscout.api",
74
- level=LogLevel.INFO,
75
- handlers=[ConsoleHandler(stream=sys.stdout)],
76
- fmt=LogFormat.DEFAULT
77
- )
78
-
79
-
80
- class ServerConfig:
81
- """Centralized configuration management for the API server."""
82
-
83
- def __init__(self):
84
- self.api_key: Optional[str] = None
85
- self.provider_map: Dict[str, Any] = {}
86
- self.default_provider: str = "ChatGPT"
87
- self.base_url: Optional[str] = None
88
- self.host: str = DEFAULT_HOST
89
- self.port: int = DEFAULT_PORT
90
- self.debug: bool = False
91
- self.cors_origins: List[str] = ["*"]
92
- self.max_request_size: int = 10 * 1024 * 1024 # 10MB
93
- self.request_timeout: int = 300 # 5 minutes
94
-
95
- def update(self, **kwargs) -> None:
96
- """Update configuration with provided values."""
97
- for key, value in kwargs.items():
98
- if hasattr(self, key) and value is not None:
99
- setattr(self, key, value)
100
- logger.info(f"Config updated: {key} = {value}")
101
-
102
- def validate(self) -> None:
103
- """Validate configuration settings."""
104
- if self.port < 1 or self.port > 65535:
105
- raise ValueError(f"Invalid port number: {self.port}")
106
-
107
- if self.default_provider not in self.provider_map and self.provider_map:
108
- available_providers = list(set(v.__name__ for v in self.provider_map.values()))
109
- logger.warning(f"Default provider '{self.default_provider}' not found. Available: {available_providers}")
110
-
111
-
112
- # Global configuration instance
113
- config = ServerConfig()
114
-
115
- # Cache for provider instances to avoid reinitialization on every request
116
- provider_instances: Dict[str, Any] = {}
117
- tti_provider_instances: Dict[str, Any] = {}
118
-
119
-
120
- # Define Pydantic models for multimodal content parts, aligning with OpenAI's API
121
- class TextPart(BaseModel):
122
- """Text content part for multimodal messages."""
123
- type: Literal["text"]
124
- text: str
125
-
126
-
127
- class ImageURL(BaseModel):
128
- """Image URL configuration for multimodal messages."""
129
- url: str # Can be http(s) or data URI
130
- detail: Optional[Literal["auto", "low", "high"]] = Field(
131
- "auto",
132
- description="Specifies the detail level of the image."
133
- )
134
-
135
-
136
- class ImagePart(BaseModel):
137
- """Image content part for multimodal messages."""
138
- type: Literal["image_url"]
139
- image_url: ImageURL
140
-
141
-
142
- MessageContentParts = Union[TextPart, ImagePart]
143
-
144
-
145
- class Message(BaseModel):
146
- """Chat message model compatible with OpenAI API."""
147
- role: Literal["system", "user", "assistant", "function", "tool"]
148
- content: Optional[Union[str, List[MessageContentParts]]] = Field(
149
- None,
150
- description="The content of the message. Can be a string, a list of content parts (for multimodal), or null."
151
- )
152
- name: Optional[str] = None
153
- # Future: Add tool_calls and tool_call_id for function calling support
154
- # tool_calls: Optional[List[ToolCall]] = None
155
- # tool_call_id: Optional[str] = None
156
-
157
- class ChatCompletionRequest(BaseModel):
158
- model: str = Field(..., description="ID of the model to use. See the model endpoint for the available models.")
159
- messages: List[Message] = Field(..., description="A list of messages comprising the conversation so far.")
160
- temperature: Optional[float] = Field(None, description="What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.")
161
- top_p: Optional[float] = Field(None, description="An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.")
162
- n: Optional[int] = Field(1, description="How many chat completion choices to generate for each input message.")
163
- stream: Optional[bool] = Field(False, description="If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: [DONE] message.")
164
- max_tokens: Optional[int] = Field(None, description="The maximum number of tokens to generate in the chat completion.")
165
- 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, increasing the model's likelihood to talk about new topics.")
166
- 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, decreasing the model's likelihood to repeat the same line verbatim.")
167
- logit_bias: Optional[Dict[str, float]] = Field(None, description="Modify the likelihood of specified tokens appearing in the completion.")
168
- user: Optional[str] = Field(None, description="A unique identifier representing your end-user, which can help the API to monitor and detect abuse.")
169
- stop: Optional[Union[str, List[str]]] = Field(None, description="Up to 4 sequences where the API will stop generating further tokens.")
170
-
171
- class Config:
172
- extra = "ignore" # Ignore extra fields that aren't in the model
173
- schema_extra = {
174
- "example": {
175
- "model": "Cloudflare/@cf/meta/llama-4-scout-17b-16e-instruct",
176
- "messages": [
177
- {"role": "system", "content": "You are a helpful assistant."},
178
- {"role": "user", "content": "Hello, how are you?"}
179
- ],
180
- "temperature": 0.7,
181
- "max_tokens": 150,
182
- "stream": False
183
- }
184
- }
185
-
186
- class ImageGenerationRequest(BaseModel):
187
- """Request model for OpenAI-compatible image generation endpoint."""
188
- prompt: str = Field(..., description="A text description of the desired image(s). The maximum length is 1000 characters.")
189
- model: str = Field(..., description="The model to use for image generation.")
190
- n: Optional[int] = Field(1, description="The number of images to generate. Must be between 1 and 10.")
191
- size: Optional[str] = Field("1024x1024", description="The size of the generated images. Must be one of: '256x256', '512x512', or '1024x1024'.")
192
- response_format: Optional[Literal["url", "b64_json"]] = Field("url", description="The format in which the generated images are returned. Must be either 'url' or 'b64_json'.")
193
- user: Optional[str] = Field(None, description="A unique identifier representing your end-user, which can help to monitor and detect abuse.")
194
- style: Optional[str] = Field(None, description="Optional style for the image (provider/model-specific).")
195
- aspect_ratio: Optional[str] = Field(None, description="Optional aspect ratio for the image (provider/model-specific).")
196
- timeout: Optional[int] = Field(None, description="Optional timeout for the image generation request in seconds.")
197
- image_format: Optional[str] = Field(None, description="Optional image format (e.g., 'png', 'jpeg').")
198
- seed: Optional[int] = Field(None, description="Optional random seed for reproducibility.")
199
-
200
- class Config:
201
- extra = "ignore"
202
- schema_extra = {
203
- "example": {
204
- "prompt": "A futuristic cityscape at sunset, digital art",
205
- "model": "PollinationsAI/turbo",
206
- "n": 1,
207
- "size": "1024x1024",
208
- "response_format": "url",
209
- "user": "user-1234"
210
- }
211
- }
212
-
213
- class ModelInfo(BaseModel):
214
- """Model information for the models endpoint."""
215
- id: str
216
- object: str = "model"
217
- created: int
218
- owned_by: str
219
-
220
-
221
- class ModelListResponse(BaseModel):
222
- """Response model for the models list endpoint."""
223
- object: str = "list"
224
- data: List[ModelInfo]
225
-
226
-
227
- class ErrorDetail(BaseModel):
228
- """Error detail structure compatible with OpenAI API."""
229
- message: str
230
- type: str = "server_error"
231
- param: Optional[str] = None
232
- code: Optional[str] = None
233
-
234
-
235
- class ErrorResponse(BaseModel):
236
- """Error response structure compatible with OpenAI API."""
237
- error: ErrorDetail
238
-
239
-
240
- class APIError(Exception):
241
- """Custom exception for API errors."""
242
-
243
- def __init__(self, message: str, status_code: int = HTTP_500_INTERNAL_SERVER_ERROR,
244
- error_type: str = "server_error", param: Optional[str] = None,
245
- code: Optional[str] = None):
246
- self.message = message
247
- self.status_code = status_code
248
- self.error_type = error_type
249
- self.param = param
250
- self.code = code
251
- super().__init__(message)
252
-
253
- def to_response(self) -> JSONResponse:
254
- """Convert to FastAPI JSONResponse."""
255
- error_detail = ErrorDetail(
256
- message=self.message,
257
- type=self.error_type,
258
- param=self.param,
259
- code=self.code
260
- )
261
- error_response = ErrorResponse(error=error_detail)
262
- return JSONResponse(
263
- status_code=self.status_code,
264
- content=error_response.model_dump(exclude_none=True)
265
- )
266
-
267
-
268
- class AppConfig:
269
- """Legacy configuration class for backward compatibility."""
270
- api_key: Optional[str] = None
271
- provider_map = {}
272
- tti_provider_map = {} # Add TTI provider map
273
- default_provider = "ChatGPT"
274
- default_tti_provider = "PollinationsAI" # Add default TTI provider
275
- base_url: Optional[str] = None
276
-
277
- @classmethod
278
- def set_config(cls, **data):
279
- """Set configuration values."""
280
- for key, value in data.items():
281
- setattr(cls, key, value)
282
- # Sync with new config system
283
- config.update(**data)
284
-
285
- # Custom route class to handle dynamic base URLs
286
- # Note: The /docs 404 issue is likely related to server execution (Werkzeug logs vs. Uvicorn script).
287
- # This DynamicBaseRoute, when AppConfig.base_url is None, should act as a passthrough and not break /docs.
288
- # If AppConfig.base_url is set, this route class has limitations in correctly handling prefixed routes
289
- # without more complex path manipulation or using FastAPI's APIRouter prefixing/mounting features.
290
- class DynamicBaseRoute(APIRoute):
291
- def get_route_handler(self) -> Callable:
292
- original_route_handler = super().get_route_handler()
293
- async def custom_route_handler(request: Request) -> Response:
294
- if AppConfig.base_url:
295
- if not request.url.path.startswith(AppConfig.base_url):
296
- # This logic might need refinement if base_url is used.
297
- # For API routes not matching the prefix, a 404 might be appropriate.
298
- # Docs routes (/docs, /openapi.json) are usually at the root.
299
- # The current 'pass' allows root docs even if base_url is set for APIs.
300
- pass
301
- return await original_route_handler(request)
302
- return custom_route_handler
303
-
304
- def create_app():
305
- app = FastAPI(
306
- title="Webscout OpenAI API",
307
- description="OpenAI API compatible interface for various LLM providers",
308
- version="0.1.0",
309
- docs_url="/docs",
310
- redoc_url="/redoc",
311
- openapi_url="/openapi.json",
312
- )
313
- app.router.route_class = DynamicBaseRoute
314
- app.add_middleware(
315
- CORSMiddleware,
316
- allow_origins=["*"],
317
- allow_credentials=True,
318
- allow_methods=["*"],
319
- allow_headers=["*"],
320
- )
321
- api = Api(app)
322
- api.register_authorization()
323
- api.register_validation_exception_handler()
324
- api.register_routes()
325
- initialize_provider_map()
326
- initialize_tti_provider_map() # Initialize TTI providers
327
-
328
- def custom_openapi():
329
- if app.openapi_schema:
330
- return app.openapi_schema
331
-
332
- openapi_schema = get_openapi(
333
- title=app.title,
334
- version=app.version,
335
- description=app.description,
336
- routes=app.routes,
337
- )
338
-
339
- if "components" not in openapi_schema: openapi_schema["components"] = {}
340
- if "schemas" not in openapi_schema["components"]: openapi_schema["components"]["schemas"] = {}
341
-
342
- # Use Pydantic's schema generation for accuracy
343
- # Assuming Pydantic v1 .schema() or v2 .model_json_schema() based on pydantic_imports
344
- # For broader compatibility, trying .schema() first.
345
- # If using Pydantic v2 primarily, .model_json_schema() is preferred.
346
- schema_method_name = "model_json_schema" if hasattr(BaseModel, "model_json_schema") else "schema"
347
-
348
- # Add/update schemas derived from Pydantic models to ensure they are correctly defined
349
- pydantic_models_to_register = {
350
- "TextPart": TextPart,
351
- "ImageURL": ImageURL,
352
- "ImagePart": ImagePart,
353
- "Message": Message,
354
- "ChatCompletionRequest": ChatCompletionRequest,
355
- "ImageGenerationRequest": ImageGenerationRequest,
356
- }
357
-
358
- for name, model_cls in pydantic_models_to_register.items():
359
- if schema_method_name == "model_json_schema":
360
- schema_data = model_cls.model_json_schema(ref_template="#/components/schemas/{model}")
361
- else:
362
- schema_data = model_cls.schema()
363
- # Pydantic might add a "title" to the schema, which is often not desired for component schemas
364
- if "title" in schema_data:
365
- del schema_data["title"]
366
- openapi_schema["components"]["schemas"][name] = schema_data
367
-
368
- app.openapi_schema = openapi_schema
369
- return app.openapi_schema
370
-
371
- app.openapi = custom_openapi
372
- return app
373
-
374
- def create_app_debug():
375
- return create_app()
376
-
377
- def initialize_provider_map() -> None:
378
- """Initialize the provider map by discovering available providers."""
379
- logger.info("Initializing provider map...")
380
-
381
- try:
382
- from webscout.Provider.OPENAI.base import OpenAICompatibleProvider
383
- module = sys.modules["webscout.Provider.OPENAI"]
384
-
385
- provider_count = 0
386
- model_count = 0
387
-
388
- for name, obj in inspect.getmembers(module):
389
- if (
390
- inspect.isclass(obj)
391
- and issubclass(obj, OpenAICompatibleProvider)
392
- and obj.__name__ != "OpenAICompatibleProvider"
393
- ):
394
- provider_name = obj.__name__
395
- AppConfig.provider_map[provider_name] = obj
396
- config.provider_map[provider_name] = obj
397
- provider_count += 1
398
-
399
- # Register available models for this provider
400
- if hasattr(obj, "AVAILABLE_MODELS") and isinstance(
401
- obj.AVAILABLE_MODELS, (list, tuple, set)
402
- ):
403
- for model in obj.AVAILABLE_MODELS:
404
- if model and isinstance(model, str):
405
- model_key = f"{provider_name}/{model}"
406
- AppConfig.provider_map[model_key] = obj
407
- config.provider_map[model_key] = obj
408
- model_count += 1
409
-
410
- # Fallback to ChatGPT if no providers found
411
- if not AppConfig.provider_map:
412
- logger.warning("No providers found, using ChatGPT fallback")
413
- try:
414
- from webscout.Provider.OPENAI.chatgpt import ChatGPT
415
- fallback_models = ["gpt-4", "gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"]
416
-
417
- AppConfig.provider_map["ChatGPT"] = ChatGPT
418
- config.provider_map["ChatGPT"] = ChatGPT
419
-
420
- for model in fallback_models:
421
- model_key = f"ChatGPT/{model}"
422
- AppConfig.provider_map[model_key] = ChatGPT
423
- config.provider_map[model_key] = ChatGPT
424
-
425
- AppConfig.default_provider = "ChatGPT"
426
- config.default_provider = "ChatGPT"
427
- provider_count = 1
428
- model_count = len(fallback_models)
429
- except ImportError as e:
430
- logger.error(f"Failed to import ChatGPT fallback: {e}")
431
- raise APIError("No providers available", HTTP_500_INTERNAL_SERVER_ERROR)
432
-
433
- logger.info(f"Initialized {provider_count} providers with {model_count} models")
434
-
435
- except Exception as e:
436
- logger.error(f"Failed to initialize provider map: {e}")
437
- raise APIError(f"Provider initialization failed: {e}", HTTP_500_INTERNAL_SERVER_ERROR)
438
-
439
- def initialize_tti_provider_map() -> None:
440
- """Initialize the TTI provider map by discovering available TTI providers."""
441
- logger.info("Initializing TTI provider map...")
442
-
443
- try:
444
- import webscout.Provider.TTI as tti_module
445
-
446
- provider_count = 0
447
- model_count = 0
448
-
449
- for name, obj in inspect.getmembers(tti_module):
450
- if (
451
- inspect.isclass(obj)
452
- and issubclass(obj, TTICompatibleProvider)
453
- and obj.__name__ != "TTICompatibleProvider"
454
- and obj.__name__ != "BaseImages"
455
- ):
456
- provider_name = obj.__name__
457
- AppConfig.tti_provider_map[provider_name] = obj
458
- provider_count += 1
459
-
460
- # Register available models for this TTI provider
461
- if hasattr(obj, "AVAILABLE_MODELS") and isinstance(
462
- obj.AVAILABLE_MODELS, (list, tuple, set)
463
- ):
464
- for model in obj.AVAILABLE_MODELS:
465
- if model and isinstance(model, str):
466
- model_key = f"{provider_name}/{model}"
467
- AppConfig.tti_provider_map[model_key] = obj
468
- model_count += 1
469
-
470
- # Fallback to PollinationsAI if no TTI providers found
471
- if not AppConfig.tti_provider_map:
472
- logger.warning("No TTI providers found, using PollinationsAI fallback")
473
- try:
474
- from webscout.Provider.TTI.pollinations import PollinationsAI
475
- fallback_models = ["flux", "turbo", "gptimage"]
476
-
477
- AppConfig.tti_provider_map["PollinationsAI"] = PollinationsAI
478
-
479
- for model in fallback_models:
480
- model_key = f"PollinationsAI/{model}"
481
- AppConfig.tti_provider_map[model_key] = PollinationsAI
482
-
483
- AppConfig.default_tti_provider = "PollinationsAI"
484
- provider_count = 1
485
- model_count = len(fallback_models)
486
- except ImportError as e:
487
- logger.error(f"Failed to import PollinationsAI fallback: {e}")
488
- raise APIError("No TTI providers available", HTTP_500_INTERNAL_SERVER_ERROR)
489
-
490
- logger.info(f"Initialized {provider_count} TTI providers with {model_count} models")
491
-
492
- except Exception as e:
493
- logger.error(f"Failed to initialize TTI provider map: {e}")
494
- raise APIError(f"TTI Provider initialization failed: {e}", HTTP_500_INTERNAL_SERVER_ERROR)
495
-
496
- class Api:
497
- def __init__(self, app: FastAPI) -> None:
498
- self.app = app
499
- self.get_api_key = APIKeyHeader(name="authorization", auto_error=False)
500
-
501
- def register_authorization(self):
502
- @self.app.middleware("http")
503
- async def authorization(request: Request, call_next):
504
- if AppConfig.api_key is not None:
505
- auth_header = await self.get_api_key(request)
506
- path = request.url.path
507
- if path.startswith("/v1"): # Only protect /v1 routes
508
- # Also allow access to /docs, /openapi.json etc. if AppConfig.base_url is not set or path is not under it
509
- # This logic should be fine as it only protects /v1 paths
510
- if auth_header is None:
511
- return ErrorResponse.from_message("API key required", HTTP_401_UNAUTHORIZED)
512
- if auth_header.startswith("Bearer "):
513
- auth_header = auth_header[7:]
514
- if AppConfig.api_key is None or not secrets.compare_digest(AppConfig.api_key, auth_header): # AppConfig.api_key check is redundant after outer if
515
- return ErrorResponse.from_message("Invalid API key", HTTP_403_FORBIDDEN)
516
- return await call_next(request)
517
-
518
- def register_validation_exception_handler(self):
519
- """Register comprehensive exception handlers."""
520
-
521
- @self.app.exception_handler(APIError)
522
- async def api_error_handler(request: Request, exc: APIError):
523
- """Handle custom API errors."""
524
- logger.error(f"API Error: {exc.message} (Status: {exc.status_code})")
525
- return exc.to_response()
526
-
527
- @self.app.exception_handler(RequestValidationError)
528
- async def validation_exception_handler(request: Request, exc: RequestValidationError):
529
- errors = exc.errors()
530
- error_messages = []
531
- body = await request.body()
532
- is_empty_body = not body or body.strip() in (b"", b"null", b"{}")
533
- for error in errors:
534
- loc = error.get("loc", [])
535
- # Ensure loc_str is user-friendly
536
- loc_str_parts = []
537
- for item in loc:
538
- if item == "body": # Skip "body" part if it's the first element of a longer path
539
- if len(loc) > 1: continue
540
- loc_str_parts.append(str(item))
541
- loc_str = " -> ".join(loc_str_parts)
542
-
543
- msg = error.get("msg", "Validation error")
544
-
545
- # Check if this error is for the 'content' field specifically due to multimodal input
546
- if len(loc) >=3 and loc[0] == 'body' and loc[1] == 'messages' and loc[-1] == 'content':
547
- # Check if the error type suggests a string was expected but a list (or vice-versa) was given for content
548
- if "Input should be a valid string" in msg and error.get("input_type") == "list":
549
- error_messages.append({
550
- "loc": loc,
551
- "message": f"Invalid message content: {msg}. Ensure content matches the expected format (string or list of content parts). Path: {loc_str}",
552
- "type": error.get("type", "validation_error")
553
- })
554
- continue # Skip default message formatting for this specific case
555
- elif "Input should be a valid list" in msg and error.get("input_type") == "string":
556
- error_messages.append({
557
- "loc": loc,
558
- "message": f"Invalid message content: {msg}. Ensure content matches the expected format (string or list of content parts). Path: {loc_str}",
559
- "type": error.get("type", "validation_error")
560
- })
561
- continue
562
-
563
- if "body" in loc:
564
- if len(loc) > 1 and loc[1] == "messages":
565
- error_messages.append({
566
- "loc": loc,
567
- "message": "The 'messages' field is required and must be a non-empty array of message objects. " + f"Error: {msg} at {loc_str}",
568
- "type": error.get("type", "validation_error")
569
- })
570
- elif len(loc) > 1 and loc[1] == "model":
571
- error_messages.append({
572
- "loc": loc,
573
- "message": "The 'model' field is required and must be a string. " + f"Error: {msg} at {loc_str}",
574
- "type": error.get("type", "validation_error")
575
- })
576
- else:
577
- error_messages.append({
578
- "loc": loc,
579
- "message": f"{msg} at {loc_str}",
580
- "type": error.get("type", "validation_error")
581
- })
582
- else:
583
- error_messages.append({
584
- "loc": loc,
585
- "message": f"{msg} at {loc_str}",
586
- "type": error.get("type", "validation_error")
587
- })
588
- if request.url.path == "/v1/chat/completions":
589
- example = ChatCompletionRequest.Config.schema_extra["example"]
590
- if is_empty_body:
591
- return JSONResponse(
592
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
593
- content={
594
- "error": {
595
- "message": "Request body is required and must include 'model' and 'messages'.",
596
- "type": "invalid_request_error",
597
- "param": None,
598
- "code": "body_missing"
599
- },
600
- "example": example
601
- }
602
- )
603
- return JSONResponse(
604
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
605
- content={"detail": error_messages, "example": example}
606
- )
607
- return JSONResponse(
608
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
609
- content={"detail": error_messages}
610
- )
611
- @self.app.exception_handler(StarletteHTTPException)
612
- async def http_exception_handler(request: Request, exc: StarletteHTTPException):
613
- return JSONResponse(
614
- status_code=exc.status_code,
615
- content={"detail": exc.detail}
616
- )
617
- @self.app.exception_handler(Exception)
618
- async def general_exception_handler(request: Request, exc: Exception):
619
- return JSONResponse(
620
- status_code=HTTP_500_INTERNAL_SERVER_ERROR,
621
- content={"detail": f"Internal server error: {str(exc)}"}
622
- )
623
-
624
- def register_routes(self):
625
- @self.app.get("/", include_in_schema=False)
626
- async def root():
627
- # Note: If /docs is 404ing, check if server is Uvicorn (expected) or Werkzeug (from logs).
628
- # Werkzeug logs suggest possible execution of a Flask app or WSGI misconfiguration.
629
- # This api.py file is intended for Uvicorn.
630
- return RedirectResponse(url="/docs")
631
-
632
- @self.app.get("/v1/models", response_model=ModelListResponse)
633
- async def list_models():
634
- models = []
635
- for model_name, provider_class in AppConfig.provider_map.items():
636
- if "/" not in model_name:
637
- continue # Skip provider names
638
- if any(m["id"] == model_name for m in models):
639
- continue
640
- models.append({
641
- "id": model_name,
642
- "object": "model",
643
- "created": int(time.time()),
644
- "owned_by": provider_class.__name__
645
- })
646
- # Sort models alphabetically by the part after the first '/'
647
- models = sorted(models, key=lambda m: m["id"].split("/", 1)[1].lower())
648
- return {
649
- "object": "list",
650
- "data": models
651
- }
652
-
653
- @self.app.get("/v1/TTI/models", response_model=ModelListResponse)
654
- async def list_tti_models():
655
- models = []
656
- for model_name, provider_class in AppConfig.tti_provider_map.items():
657
- if "/" not in model_name:
658
- continue # Skip provider names
659
- if any(m["id"] == model_name for m in models):
660
- continue
661
- models.append({
662
- "id": model_name,
663
- "object": "model",
664
- "created": int(time.time()),
665
- "owned_by": provider_class.__name__
666
- })
667
- # Sort models alphabetically by the part after the first '/'
668
- models = sorted(models, key=lambda m: m["id"].split("/", 1)[1].lower())
669
- return {
670
- "object": "list",
671
- "data": models
672
- }
673
-
674
- @self.app.post(
675
- "/v1/chat/completions",
676
- response_model_exclude_none=True,
677
- response_model_exclude_unset=True,
678
- openapi_extra={ # This ensures the example is shown in docs
679
- "requestBody": {
680
- "content": {
681
- "application/json": {
682
- "schema": {
683
- "$ref": "#/components/schemas/ChatCompletionRequest" # Relies on custom_openapi
684
- },
685
- "example": ChatCompletionRequest.Config.schema_extra["example"]
686
- }
687
- }
688
- }
689
- }
690
- )
691
- async def chat_completions(
692
- chat_request: ChatCompletionRequest = Body(...)
693
- ):
694
- """Handle chat completion requests with comprehensive error handling."""
695
- start_time = time.time()
696
- request_id = f"chatcmpl-{uuid.uuid4()}"
697
-
698
- try:
699
- logger.info(f"Processing chat completion request {request_id} for model: {chat_request.model}")
700
-
701
- # Resolve provider and model
702
- provider_class, model_name = resolve_provider_and_model(chat_request.model)
703
-
704
- # Initialize provider with caching and error handling
705
- try:
706
- provider = get_provider_instance(provider_class)
707
- logger.debug(f"Using provider instance: {provider_class.__name__}")
708
- except Exception as e:
709
- logger.error(f"Failed to initialize provider {provider_class.__name__}: {e}")
710
- raise APIError(
711
- f"Failed to initialize provider {provider_class.__name__}: {e}",
712
- HTTP_500_INTERNAL_SERVER_ERROR,
713
- "provider_error"
714
- )
715
-
716
- # Process and validate messages
717
- processed_messages = process_messages(chat_request.messages)
718
-
719
- # Prepare parameters for provider
720
- params = prepare_provider_params(chat_request, model_name, processed_messages)
721
-
722
- # Handle streaming vs non-streaming
723
- if chat_request.stream:
724
- return await handle_streaming_response(provider, params, request_id)
725
- else:
726
- return await handle_non_streaming_response(provider, params, request_id, start_time)
727
-
728
- except APIError:
729
- # Re-raise API errors as-is
730
- raise
731
- except Exception as e:
732
- logger.error(f"Unexpected error in chat completion {request_id}: {e}")
733
- raise APIError(
734
- f"Internal server error: {str(e)}",
735
- HTTP_500_INTERNAL_SERVER_ERROR,
736
- "internal_error"
737
- )
738
-
739
- @self.app.post(
740
- "/v1/images/generations",
741
- response_model_exclude_none=True,
742
- response_model_exclude_unset=True,
743
- openapi_extra={
744
- "requestBody": {
745
- "content": {
746
- "application/json": {
747
- "schema": {
748
- "$ref": "#/components/schemas/ImageGenerationRequest"
749
- },
750
- "example": ImageGenerationRequest.Config.schema_extra["example"]
751
- }
752
- }
753
- } }
754
- )
755
- async def image_generations(
756
- image_request: ImageGenerationRequest = Body(...)
757
- ):
758
- """Handle image generation requests (OpenAI-compatible)."""
759
- request_id = f"imggen-{uuid.uuid4()}"
760
- try:
761
- logger.info(f"Processing image generation request {request_id} for model: {image_request.model}")
762
- # Provider/model resolution using TTI providers
763
- provider_class, model_name = resolve_tti_provider_and_model(image_request.model)
764
- # Initialize provider with caching
765
- try:
766
- provider = get_tti_provider_instance(provider_class)
767
- logger.debug(f"Using TTI provider instance: {provider_class.__name__}")
768
- except Exception as e:
769
- logger.error(f"Failed to initialize provider {provider_class.__name__}: {e}")
770
- raise APIError(
771
- f"Failed to initialize provider {provider_class.__name__}: {e}",
772
- HTTP_500_INTERNAL_SERVER_ERROR,
773
- "provider_error"
774
- )
775
- # Prepare parameters for provider
776
- params = {
777
- "model": model_name,
778
- "prompt": image_request.prompt,
779
- "n": image_request.n,
780
- "size": image_request.size,
781
- "response_format": image_request.response_format,
782
- "user": image_request.user,
783
- "style": image_request.style,
784
- "aspect_ratio": image_request.aspect_ratio,
785
- "timeout": image_request.timeout,
786
- "image_format": image_request.image_format,
787
- "seed": image_request.seed,
788
- }
789
- # Remove None values
790
- params = {k: v for k, v in params.items() if v is not None}
791
- # Call provider
792
- try:
793
- result = provider.images.create(**params)
794
- except Exception as e:
795
- logger.error(f"Error in image generation for request {request_id}: {e}")
796
- raise APIError(
797
- f"Provider error: {str(e)}",
798
- HTTP_500_INTERNAL_SERVER_ERROR,
799
- "provider_error"
800
- )
801
- # Standardize response
802
- if hasattr(result, "model_dump"):
803
- response_data = result.model_dump(exclude_none=True)
804
- elif hasattr(result, "dict"):
805
- response_data = result.dict(exclude_none=True)
806
- elif isinstance(result, dict):
807
- response_data = result
808
- else:
809
- raise APIError(
810
- "Invalid response format from provider",
811
- HTTP_500_INTERNAL_SERVER_ERROR,
812
- "provider_error"
813
- )
814
- return response_data
815
- except APIError:
816
- raise
817
- except Exception as e:
818
- logger.error(f"Unexpected error in image generation {request_id}: {e}")
819
- raise APIError(
820
- f"Internal server error: {str(e)}",
821
- HTTP_500_INTERNAL_SERVER_ERROR,
822
- "internal_error"
823
- )
824
-
825
-
826
- def resolve_provider_and_model(model_identifier: str) -> tuple[Any, str]:
827
- """Resolve provider class and model name from model identifier."""
828
- provider_class = None
829
- model_name = None
830
-
831
- # Check for explicit provider/model syntax
832
- if model_identifier in AppConfig.provider_map and "/" in model_identifier:
833
- provider_class = AppConfig.provider_map[model_identifier]
834
- _, model_name = model_identifier.split("/", 1)
835
- elif "/" in model_identifier:
836
- provider_name, model_name = model_identifier.split("/", 1)
837
- provider_class = AppConfig.provider_map.get(provider_name)
838
- else:
839
- provider_class = AppConfig.provider_map.get(AppConfig.default_provider)
840
- model_name = model_identifier
841
-
842
- if not provider_class:
843
- available_providers = list(set(v.__name__ for v in AppConfig.provider_map.values()))
844
- raise APIError(
845
- f"Provider for model '{model_identifier}' not found. Available providers: {available_providers}",
846
- HTTP_404_NOT_FOUND,
847
- "model_not_found",
848
- param="model"
849
- )
850
-
851
- # Validate model availability
852
- if hasattr(provider_class, "AVAILABLE_MODELS") and model_name is not None:
853
- available = getattr(provider_class, "AVAILABLE_MODELS", None)
854
- # If it's a property, get from instance
855
- if isinstance(available, property):
856
- try:
857
- available = getattr(provider_class(), "AVAILABLE_MODELS", [])
858
- except Exception:
859
- available = []
860
- # If still not iterable, fallback to empty list
861
- if not isinstance(available, (list, tuple, set)):
862
- available = list(available) if hasattr(available, "__iter__") and not isinstance(available, str) else []
863
- if available and model_name not in available:
864
- raise APIError(
865
- f"Model '{model_name}' not supported by provider '{provider_class.__name__}'. Available models: {available}",
866
- HTTP_404_NOT_FOUND,
867
- "model_not_found",
868
- param="model"
869
- )
870
-
871
- return provider_class, model_name
872
-
873
- def resolve_tti_provider_and_model(model_identifier: str) -> tuple[Any, str]:
874
- """Resolve TTI provider class and model name from model identifier."""
875
- provider_class = None
876
- model_name = None
877
-
878
- # Check for explicit provider/model syntax
879
- if model_identifier in AppConfig.tti_provider_map and "/" in model_identifier:
880
- provider_class = AppConfig.tti_provider_map[model_identifier]
881
- _, model_name = model_identifier.split("/", 1)
882
- elif "/" in model_identifier:
883
- provider_name, model_name = model_identifier.split("/", 1)
884
- provider_class = AppConfig.tti_provider_map.get(provider_name)
885
- else:
886
- provider_class = AppConfig.tti_provider_map.get(AppConfig.default_tti_provider)
887
- model_name = model_identifier
888
-
889
- if not provider_class:
890
- available_providers = list(set(v.__name__ for v in AppConfig.tti_provider_map.values()))
891
- raise APIError(
892
- f"TTI Provider for model '{model_identifier}' not found. Available TTI providers: {available_providers}",
893
- HTTP_404_NOT_FOUND,
894
- "model_not_found",
895
- param="model"
896
- )
897
-
898
- # Validate model availability
899
- if hasattr(provider_class, "AVAILABLE_MODELS") and model_name is not None:
900
- available = getattr(provider_class, "AVAILABLE_MODELS", None)
901
- # If it's a property, get from instance
902
- if isinstance(available, property):
903
- try:
904
- available = getattr(provider_class(), "AVAILABLE_MODELS", [])
905
- except Exception:
906
- available = []
907
- # If still not iterable, fallback to empty list
908
- if not isinstance(available, (list, tuple, set)):
909
- available = list(available) if hasattr(available, "__iter__") and not isinstance(available, str) else []
910
- if available and model_name not in available:
911
- raise APIError(
912
- f"Model '{model_name}' not supported by TTI provider '{provider_class.__name__}'. Available models: {available}",
913
- HTTP_404_NOT_FOUND,
914
- "model_not_found",
915
- param="model"
916
- )
917
-
918
- return provider_class, model_name
919
-
920
-
921
- def get_provider_instance(provider_class: Any):
922
- """Return a cached instance of the provider, creating it if necessary."""
923
- key = provider_class.__name__
924
- instance = provider_instances.get(key)
925
- if instance is None:
926
- instance = provider_class()
927
- provider_instances[key] = instance
928
- return instance
929
-
930
-
931
- def get_tti_provider_instance(provider_class: Any):
932
- """Return a cached instance of the TTI provider, creating it if needed."""
933
- key = provider_class.__name__
934
- instance = tti_provider_instances.get(key)
935
- if instance is None:
936
- instance = provider_class()
937
- tti_provider_instances[key] = instance
938
- return instance
939
-
940
-
941
- def process_messages(messages: List[Message]) -> List[Dict[str, Any]]:
942
- """Process and validate chat messages."""
943
- processed_messages = []
944
-
945
- for i, msg_in in enumerate(messages):
946
- try:
947
- message_dict_out = {"role": msg_in.role}
948
-
949
- if msg_in.content is None:
950
- message_dict_out["content"] = None
951
- elif isinstance(msg_in.content, str):
952
- message_dict_out["content"] = msg_in.content
953
- else: # List[MessageContentParts]
954
- message_dict_out["content"] = [
955
- part.model_dump(exclude_none=True) for part in msg_in.content
956
- ]
957
-
958
- if msg_in.name:
959
- message_dict_out["name"] = msg_in.name
960
-
961
- processed_messages.append(message_dict_out)
962
-
963
- except Exception as e:
964
- raise APIError(
965
- f"Invalid message at index {i}: {str(e)}",
966
- HTTP_422_UNPROCESSABLE_ENTITY,
967
- "invalid_request_error",
968
- param=f"messages[{i}]"
969
- )
970
-
971
- return processed_messages
972
-
973
-
974
- def prepare_provider_params(chat_request: ChatCompletionRequest, model_name: str,
975
- processed_messages: List[Dict[str, Any]]) -> Dict[str, Any]:
976
- """Prepare parameters for the provider."""
977
- params = {
978
- "model": model_name,
979
- "messages": processed_messages,
980
- "stream": chat_request.stream,
981
- }
982
-
983
- # Add optional parameters if present
984
- optional_params = [
985
- "temperature", "max_tokens", "top_p", "presence_penalty",
986
- "frequency_penalty", "stop", "user"
987
- ]
988
-
989
- for param in optional_params:
990
- value = getattr(chat_request, param, None)
991
- if value is not None:
992
- params[param] = value
993
-
994
- return params
995
-
996
-
997
- async def handle_streaming_response(provider: Any, params: Dict[str, Any], request_id: str) -> StreamingResponse:
998
- """Handle streaming chat completion response."""
999
- async def streaming():
1000
- try:
1001
- logger.debug(f"Starting streaming response for request {request_id}")
1002
- completion_stream = provider.chat.completions.create(**params)
1003
-
1004
- # Check if it's iterable (generator, iterator, or other iterable types)
1005
- if hasattr(completion_stream, '__iter__') and not isinstance(completion_stream, (str, bytes, dict)):
1006
- try:
1007
- for chunk in completion_stream:
1008
- # Standardize chunk format before sending
1009
- if hasattr(chunk, 'model_dump'): # Pydantic v2
1010
- chunk_data = chunk.model_dump(exclude_none=True)
1011
- elif hasattr(chunk, 'dict'): # Pydantic v1
1012
- chunk_data = chunk.dict(exclude_none=True)
1013
- elif isinstance(chunk, dict):
1014
- chunk_data = chunk
1015
- else: # Fallback for unknown chunk types
1016
- chunk_data = chunk
1017
-
1018
- # Clean text content in the chunk to remove control characters
1019
- if isinstance(chunk_data, dict) and 'choices' in chunk_data:
1020
- for choice in chunk_data.get('choices', []):
1021
- if isinstance(choice, dict):
1022
- # Handle delta for streaming
1023
- if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
1024
- choice['delta']['content'] = clean_text(choice['delta']['content'])
1025
- # Handle message for non-streaming
1026
- elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
1027
- choice['message']['content'] = clean_text(choice['message']['content'])
1028
-
1029
- yield f"data: {json.dumps(chunk_data, ensure_ascii=False)}\n\n"
1030
- except TypeError as te:
1031
- logger.error(f"Error iterating over completion_stream: {te}")
1032
- # Fall back to treating as non-generator response
1033
- if hasattr(completion_stream, 'model_dump'):
1034
- response_data = completion_stream.model_dump(exclude_none=True)
1035
- elif hasattr(completion_stream, 'dict'):
1036
- response_data = completion_stream.dict(exclude_none=True)
1037
- else:
1038
- response_data = completion_stream
1039
-
1040
- # Clean text content in the response
1041
- if isinstance(response_data, dict) and 'choices' in response_data:
1042
- for choice in response_data.get('choices', []):
1043
- if isinstance(choice, dict):
1044
- if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
1045
- choice['delta']['content'] = clean_text(choice['delta']['content'])
1046
- elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
1047
- choice['message']['content'] = clean_text(choice['message']['content'])
1048
-
1049
- yield f"data: {json.dumps(response_data, ensure_ascii=False)}\n\n"
1050
- else: # Non-generator response
1051
- if hasattr(completion_stream, 'model_dump'):
1052
- response_data = completion_stream.model_dump(exclude_none=True)
1053
- elif hasattr(completion_stream, 'dict'):
1054
- response_data = completion_stream.dict(exclude_none=True)
1055
- else:
1056
- response_data = completion_stream
1057
-
1058
- # Clean text content in the response
1059
- if isinstance(response_data, dict) and 'choices' in response_data:
1060
- for choice in response_data.get('choices', []):
1061
- if isinstance(choice, dict):
1062
- if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
1063
- choice['delta']['content'] = clean_text(choice['delta']['content'])
1064
- elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
1065
- choice['message']['content'] = clean_text(choice['message']['content'])
1066
-
1067
- yield f"data: {json.dumps(response_data, ensure_ascii=False)}\n\n"
1068
-
1069
- except Exception as e:
1070
- logger.error(f"Error in streaming response for request {request_id}: {e}")
1071
- error_message = clean_text(str(e))
1072
- error_data = {
1073
- "error": {
1074
- "message": error_message,
1075
- "type": "server_error",
1076
- "code": "streaming_error"
1077
- }
1078
- }
1079
- yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
1080
- finally:
1081
- yield "data: [DONE]\n\n"
1082
- return StreamingResponse(streaming(), media_type="text/event-stream")
1083
-
1084
-
1085
- async def handle_non_streaming_response(provider: Any, params: Dict[str, Any],
1086
- request_id: str, start_time: float) -> Dict[str, Any]:
1087
- """Handle non-streaming chat completion response."""
1088
- try:
1089
- logger.debug(f"Starting non-streaming response for request {request_id}")
1090
- completion = provider.chat.completions.create(**params)
1091
-
1092
- if completion is None:
1093
- # Return a valid OpenAI-compatible error response
1094
- return ChatCompletion(
1095
- id=request_id,
1096
- created=int(time.time()),
1097
- model=params.get("model", "unknown"),
1098
- choices=[Choice(
1099
- index=0,
1100
- message=ChatCompletionMessage(role="assistant", content="No response generated."),
1101
- finish_reason="error"
1102
- )],
1103
- usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
1104
- ).model_dump(exclude_none=True)
1105
-
1106
- # Standardize response format
1107
- if hasattr(completion, "model_dump"): # Pydantic v2
1108
- response_data = completion.model_dump(exclude_none=True)
1109
- elif hasattr(completion, "dict"): # Pydantic v1
1110
- response_data = completion.dict(exclude_none=True)
1111
- elif isinstance(completion, dict):
1112
- response_data = completion
1113
- else:
1114
- raise APIError(
1115
- "Invalid response format from provider",
1116
- HTTP_500_INTERNAL_SERVER_ERROR,
1117
- "provider_error"
1118
- )
1119
-
1120
- # Clean text content in the response to remove control characters
1121
- if isinstance(response_data, dict) and 'choices' in response_data:
1122
- for choice in response_data.get('choices', []):
1123
- if isinstance(choice, dict) and 'message' in choice:
1124
- if isinstance(choice['message'], dict) and 'content' in choice['message']:
1125
- choice['message']['content'] = clean_text(choice['message']['content'])
1126
-
1127
- elapsed = time.time() - start_time
1128
- logger.info(f"Completed non-streaming request {request_id} in {elapsed:.2f}s")
1129
-
1130
- return response_data
1131
-
1132
- except Exception as e:
1133
- logger.error(f"Error in non-streaming response for request {request_id}: {e}")
1134
- error_message = clean_text(str(e))
1135
- raise APIError(
1136
- f"Provider error: {error_message}",
1137
- HTTP_500_INTERNAL_SERVER_ERROR,
1138
- "provider_error"
1139
- )
1140
-
1141
- def format_exception(e: Union[Exception, str]) -> str:
1142
- if isinstance(e, str):
1143
- message = e
1144
- else:
1145
- message = f"{e.__class__.__name__}: {str(e)}" # Keep it concise
1146
- return json.dumps({
1147
- "error": {
1148
- "message": message,
1149
- "type": "server_error", # Or more specific if possible
1150
- "param": None,
1151
- "code": "internal_server_error" # Or more specific
1152
- }
1153
- })
1154
-
1155
- def start_server(
1156
- port: int = DEFAULT_PORT,
1157
- host: str = DEFAULT_HOST,
1158
- api_key: str = None,
1159
- default_provider: str = None,
1160
- base_url: str = None,
1161
- workers: int = 1,
1162
- log_level: str = 'info',
1163
- debug: bool = False
1164
- ):
1165
- """Start the API server with the given configuration."""
1166
- run_api(
1167
- host=host,
1168
- port=port,
1169
- api_key=api_key,
1170
- default_provider=default_provider,
1171
- base_url=base_url,
1172
- workers=workers,
1173
- log_level=log_level,
1174
- debug=debug,
1175
- )
1176
-
1177
- def run_api(
1178
- host: str = '0.0.0.0',
1179
- port: int = None,
1180
- api_key: str = None,
1181
- default_provider: str = None,
1182
- base_url: str = None,
1183
- debug: bool = False,
1184
- workers: int = 1,
1185
- log_level: str = 'info',
1186
- show_available_providers: bool = True,
1187
- ) -> None:
1188
- print("Starting Webscout OpenAI API server...")
1189
- if port is None:
1190
- port = DEFAULT_PORT
1191
- AppConfig.set_config(
1192
- api_key=api_key,
1193
- default_provider=default_provider or AppConfig.default_provider,
1194
- base_url=base_url
1195
- )
1196
- # initialize_provider_map() # This is called inside create_app now.
1197
- # Call here if create_app doesn't exist yet or for early info.
1198
- # For showing providers, it needs to be called before printing.
1199
- if show_available_providers: # Initialize map if needed for display before app creation
1200
- if not AppConfig.provider_map: # Avoid re-initializing if already done by app creation logic path
1201
- initialize_provider_map()
1202
- if not AppConfig.tti_provider_map:
1203
- initialize_tti_provider_map() # Ensure TTI providers are initialized for display
1204
-
1205
- print("\n=== Webscout OpenAI API Server ===")
1206
- print(f"Server URL: http://{host if host != '0.0.0.0' else 'localhost'}:{port}")
1207
- if AppConfig.base_url:
1208
- print(f"Base Path: {AppConfig.base_url}")
1209
- api_endpoint_base = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}{AppConfig.base_url}"
1210
- else:
1211
- api_endpoint_base = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}"
1212
-
1213
- print(f"API Endpoint: {api_endpoint_base}/v1/chat/completions")
1214
- print(f"Docs URL: {api_endpoint_base}/docs") # Adjusted for potential base_url in display
1215
- print(f"API Authentication: {'Enabled' if api_key else 'Disabled'}")
1216
- print(f"Default Provider: {AppConfig.default_provider}")
1217
- print(f"Workers: {workers}")
1218
- print(f"Log Level: {log_level}")
1219
- print(f"Debug Mode: {'Enabled' if debug else 'Disabled'}")
1220
-
1221
- providers = list(set(v.__name__ for v in AppConfig.provider_map.values()))
1222
- print(f"\n--- Available Providers ({len(providers)}) ---")
1223
- for i, provider_name in enumerate(sorted(providers), 1):
1224
- print(f"{i}. {provider_name}")
1225
-
1226
- provider_class_names = set(v.__name__ for v in AppConfig.provider_map.values())
1227
- models = sorted([model for model in AppConfig.provider_map.keys() if model not in provider_class_names])
1228
- if models:
1229
- print(f"\n--- Available Models ({len(models)}) ---")
1230
- for i, model_name in enumerate(models, 1):
1231
- print(f"{i}. {model_name} (via {AppConfig.provider_map[model_name].__name__})")
1232
- else:
1233
- print("\nNo specific models registered. Use provider names as models.")
1234
-
1235
- tti_providers = list(set(v.__name__ for v in AppConfig.tti_provider_map.values()))
1236
- print(f"\n--- Available TTI Providers ({len(tti_providers)}) ---")
1237
- for i, provider_name in enumerate(sorted(tti_providers), 1):
1238
- print(f"{i}. {provider_name}")
1239
-
1240
- tti_models = sorted([model for model in AppConfig.tti_provider_map.keys() if model not in tti_providers])
1241
- if tti_models:
1242
- print(f"\n--- Available TTI Models ({len(tti_models)}) ---")
1243
- for i, model_name in enumerate(tti_models, 1):
1244
- print(f"{i}. {model_name} (via {AppConfig.tti_provider_map[model_name].__name__})")
1245
- else:
1246
- print("\nNo specific TTI models registered. Use TTI provider names as models.")
1247
-
1248
- print("\nUse Ctrl+C to stop the server.")
1249
- print("=" * 40 + "\n")
1250
-
1251
- uvicorn_app_str = "webscout.Provider.OPENAI.api:create_app_debug" if debug else "webscout.Provider.OPENAI.api:create_app"
1252
-
1253
- # Configure uvicorn settings
1254
- uvicorn_config = {
1255
- "app": uvicorn_app_str,
1256
- "host": host,
1257
- "port": int(port),
1258
- "factory": True,
1259
- "reload": debug, # Enable reload only in debug mode for stability
1260
- "log_level": log_level.lower() if log_level else ("debug" if debug else "info"),
1261
- }
1262
-
1263
- # Add workers only if not in debug mode (reload and workers are incompatible)
1264
- if not debug and workers > 1:
1265
- uvicorn_config["workers"] = workers
1266
- print(f"Starting with {workers} workers...")
1267
- elif debug:
1268
- print("Debug mode enabled - using single worker with reload...")
1269
-
1270
- # Note: Logs show "werkzeug". If /docs 404s persist, ensure Uvicorn is the actual server running.
1271
- # The script uses uvicorn.run, so "werkzeug" logs are unexpected for this file.
1272
- uvicorn.run(**uvicorn_config)
1273
-
1274
- if __name__ == "__main__":
1275
- import argparse
1276
-
1277
- # Read environment variables with fallbacks
1278
- default_port = int(os.getenv('WEBSCOUT_PORT', os.getenv('PORT', DEFAULT_PORT)))
1279
- default_host = os.getenv('WEBSCOUT_HOST', DEFAULT_HOST)
1280
- default_workers = int(os.getenv('WEBSCOUT_WORKERS', '1'))
1281
- default_log_level = os.getenv('WEBSCOUT_LOG_LEVEL', 'info')
1282
- default_api_key = os.getenv('WEBSCOUT_API_KEY', os.getenv('API_KEY'))
1283
- default_provider = os.getenv('WEBSCOUT_DEFAULT_PROVIDER', os.getenv('DEFAULT_PROVIDER'))
1284
- default_base_url = os.getenv('WEBSCOUT_BASE_URL', os.getenv('BASE_URL'))
1285
- default_debug = os.getenv('WEBSCOUT_DEBUG', os.getenv('DEBUG', 'false')).lower() == 'true'
1286
-
1287
- parser = argparse.ArgumentParser(description='Start Webscout OpenAI-compatible API server')
1288
- parser.add_argument('--port', type=int, default=default_port, help=f'Port to run the server on (default: {default_port})')
1289
- parser.add_argument('--host', type=str, default=default_host, help=f'Host to bind the server to (default: {default_host})')
1290
- parser.add_argument('--workers', type=int, default=default_workers, help=f'Number of worker processes (default: {default_workers})')
1291
- parser.add_argument('--log-level', type=str, default=default_log_level, choices=['debug', 'info', 'warning', 'error', 'critical'], help=f'Log level (default: {default_log_level})')
1292
- parser.add_argument('--api-key', type=str, default=default_api_key, help='API key for authentication (optional)')
1293
- parser.add_argument('--default-provider', type=str, default=default_provider, help='Default provider to use (optional)')
1294
- parser.add_argument('--base-url', type=str, default=default_base_url, help='Base URL for the API (optional, e.g., /api/v1)')
1295
- parser.add_argument('--debug', action='store_true', default=default_debug, help='Run in debug mode')
1296
- args = parser.parse_args()
1297
-
1298
- # Print configuration summary
1299
- print(f"Configuration:")
1300
- print(f" Host: {args.host}")
1301
- print(f" Port: {args.port}")
1302
- print(f" Workers: {args.workers}")
1303
- print(f" Log Level: {args.log_level}")
1304
- print(f" Debug Mode: {args.debug}")
1305
- print(f" API Key: {'Set' if args.api_key else 'Not set'}")
1306
- print(f" Default Provider: {args.default_provider or 'Not set'}")
1307
- print(f" Base URL: {args.base_url or 'Not set'}")
1308
- print()
1309
-
1310
- run_api(
1311
- host=args.host,
1312
- port=args.port,
1313
- workers=args.workers,
1314
- log_level=args.log_level,
1315
- api_key=args.api_key,
1316
- default_provider=args.default_provider,
1317
- base_url=args.base_url,
1318
- debug=args.debug
1319
- )
1320
-