webscout 8.2.8__py3-none-any.whl → 8.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 (197) hide show
  1. webscout/AIauto.py +34 -16
  2. webscout/AIbase.py +96 -37
  3. webscout/AIutel.py +491 -87
  4. webscout/Bard.py +441 -323
  5. webscout/Extra/GitToolkit/__init__.py +10 -10
  6. webscout/Extra/YTToolkit/ytapi/video.py +232 -232
  7. webscout/Litlogger/README.md +10 -0
  8. webscout/Litlogger/__init__.py +7 -59
  9. webscout/Litlogger/formats.py +4 -0
  10. webscout/Litlogger/handlers.py +103 -0
  11. webscout/Litlogger/levels.py +13 -0
  12. webscout/Litlogger/logger.py +92 -0
  13. webscout/Provider/AISEARCH/Perplexity.py +332 -358
  14. webscout/Provider/AISEARCH/felo_search.py +9 -35
  15. webscout/Provider/AISEARCH/genspark_search.py +30 -56
  16. webscout/Provider/AISEARCH/hika_search.py +4 -16
  17. webscout/Provider/AISEARCH/iask_search.py +410 -436
  18. webscout/Provider/AISEARCH/monica_search.py +4 -30
  19. webscout/Provider/AISEARCH/scira_search.py +6 -32
  20. webscout/Provider/AISEARCH/webpilotai_search.py +38 -64
  21. webscout/Provider/Blackboxai.py +155 -35
  22. webscout/Provider/ChatSandbox.py +2 -1
  23. webscout/Provider/Deepinfra.py +339 -339
  24. webscout/Provider/ExaChat.py +358 -358
  25. webscout/Provider/Gemini.py +169 -169
  26. webscout/Provider/GithubChat.py +1 -2
  27. webscout/Provider/Glider.py +3 -3
  28. webscout/Provider/HeckAI.py +172 -82
  29. webscout/Provider/LambdaChat.py +1 -0
  30. webscout/Provider/MCPCore.py +7 -3
  31. webscout/Provider/OPENAI/BLACKBOXAI.py +421 -139
  32. webscout/Provider/OPENAI/Cloudflare.py +38 -21
  33. webscout/Provider/OPENAI/FalconH1.py +457 -0
  34. webscout/Provider/OPENAI/FreeGemini.py +35 -18
  35. webscout/Provider/OPENAI/NEMOTRON.py +34 -34
  36. webscout/Provider/OPENAI/PI.py +427 -0
  37. webscout/Provider/OPENAI/Qwen3.py +304 -0
  38. webscout/Provider/OPENAI/README.md +952 -1253
  39. webscout/Provider/OPENAI/TwoAI.py +374 -0
  40. webscout/Provider/OPENAI/__init__.py +7 -1
  41. webscout/Provider/OPENAI/ai4chat.py +73 -63
  42. webscout/Provider/OPENAI/api.py +869 -644
  43. webscout/Provider/OPENAI/base.py +2 -0
  44. webscout/Provider/OPENAI/c4ai.py +34 -13
  45. webscout/Provider/OPENAI/chatgpt.py +575 -556
  46. webscout/Provider/OPENAI/chatgptclone.py +512 -487
  47. webscout/Provider/OPENAI/chatsandbox.py +11 -6
  48. webscout/Provider/OPENAI/copilot.py +258 -0
  49. webscout/Provider/OPENAI/deepinfra.py +327 -318
  50. webscout/Provider/OPENAI/e2b.py +140 -104
  51. webscout/Provider/OPENAI/exaai.py +420 -411
  52. webscout/Provider/OPENAI/exachat.py +448 -443
  53. webscout/Provider/OPENAI/flowith.py +7 -3
  54. webscout/Provider/OPENAI/freeaichat.py +12 -8
  55. webscout/Provider/OPENAI/glider.py +15 -8
  56. webscout/Provider/OPENAI/groq.py +5 -2
  57. webscout/Provider/OPENAI/heckai.py +311 -307
  58. webscout/Provider/OPENAI/llmchatco.py +9 -7
  59. webscout/Provider/OPENAI/mcpcore.py +18 -9
  60. webscout/Provider/OPENAI/multichat.py +7 -5
  61. webscout/Provider/OPENAI/netwrck.py +16 -11
  62. webscout/Provider/OPENAI/oivscode.py +290 -0
  63. webscout/Provider/OPENAI/opkfc.py +507 -496
  64. webscout/Provider/OPENAI/pydantic_imports.py +172 -0
  65. webscout/Provider/OPENAI/scirachat.py +29 -17
  66. webscout/Provider/OPENAI/sonus.py +308 -303
  67. webscout/Provider/OPENAI/standardinput.py +442 -433
  68. webscout/Provider/OPENAI/textpollinations.py +18 -11
  69. webscout/Provider/OPENAI/toolbaz.py +419 -413
  70. webscout/Provider/OPENAI/typefully.py +17 -10
  71. webscout/Provider/OPENAI/typegpt.py +21 -11
  72. webscout/Provider/OPENAI/uncovrAI.py +477 -462
  73. webscout/Provider/OPENAI/utils.py +90 -79
  74. webscout/Provider/OPENAI/venice.py +435 -425
  75. webscout/Provider/OPENAI/wisecat.py +387 -381
  76. webscout/Provider/OPENAI/writecream.py +166 -163
  77. webscout/Provider/OPENAI/x0gpt.py +26 -37
  78. webscout/Provider/OPENAI/yep.py +384 -356
  79. webscout/Provider/PI.py +2 -1
  80. webscout/Provider/TTI/README.md +55 -101
  81. webscout/Provider/TTI/__init__.py +4 -9
  82. webscout/Provider/TTI/aiarta.py +365 -0
  83. webscout/Provider/TTI/artbit.py +0 -0
  84. webscout/Provider/TTI/base.py +64 -0
  85. webscout/Provider/TTI/fastflux.py +200 -0
  86. webscout/Provider/TTI/magicstudio.py +201 -0
  87. webscout/Provider/TTI/piclumen.py +203 -0
  88. webscout/Provider/TTI/pixelmuse.py +225 -0
  89. webscout/Provider/TTI/pollinations.py +221 -0
  90. webscout/Provider/TTI/utils.py +11 -0
  91. webscout/Provider/TTS/__init__.py +2 -1
  92. webscout/Provider/TTS/base.py +159 -159
  93. webscout/Provider/TTS/openai_fm.py +129 -0
  94. webscout/Provider/TextPollinationsAI.py +308 -308
  95. webscout/Provider/TwoAI.py +239 -44
  96. webscout/Provider/UNFINISHED/Youchat.py +330 -330
  97. webscout/Provider/UNFINISHED/puterjs.py +635 -0
  98. webscout/Provider/UNFINISHED/test_lmarena.py +119 -119
  99. webscout/Provider/Writecream.py +246 -246
  100. webscout/Provider/__init__.py +2 -2
  101. webscout/Provider/ai4chat.py +33 -8
  102. webscout/Provider/granite.py +41 -6
  103. webscout/Provider/koala.py +169 -169
  104. webscout/Provider/oivscode.py +309 -0
  105. webscout/Provider/samurai.py +3 -2
  106. webscout/Provider/scnet.py +1 -0
  107. webscout/Provider/typegpt.py +3 -3
  108. webscout/Provider/uncovr.py +368 -368
  109. webscout/client.py +70 -0
  110. webscout/litprinter/__init__.py +58 -58
  111. webscout/optimizers.py +419 -419
  112. webscout/scout/README.md +3 -1
  113. webscout/scout/core/crawler.py +134 -64
  114. webscout/scout/core/scout.py +148 -109
  115. webscout/scout/element.py +106 -88
  116. webscout/swiftcli/Readme.md +323 -323
  117. webscout/swiftcli/plugins/manager.py +9 -2
  118. webscout/version.py +1 -1
  119. webscout/zeroart/__init__.py +134 -134
  120. webscout/zeroart/effects.py +100 -100
  121. webscout/zeroart/fonts.py +1238 -1238
  122. {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/METADATA +160 -35
  123. webscout-8.3.dist-info/RECORD +290 -0
  124. {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/WHEEL +1 -1
  125. {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/entry_points.txt +1 -0
  126. webscout/Litlogger/Readme.md +0 -175
  127. webscout/Litlogger/core/__init__.py +0 -6
  128. webscout/Litlogger/core/level.py +0 -23
  129. webscout/Litlogger/core/logger.py +0 -165
  130. webscout/Litlogger/handlers/__init__.py +0 -12
  131. webscout/Litlogger/handlers/console.py +0 -33
  132. webscout/Litlogger/handlers/file.py +0 -143
  133. webscout/Litlogger/handlers/network.py +0 -173
  134. webscout/Litlogger/styles/__init__.py +0 -7
  135. webscout/Litlogger/styles/colors.py +0 -249
  136. webscout/Litlogger/styles/formats.py +0 -458
  137. webscout/Litlogger/styles/text.py +0 -87
  138. webscout/Litlogger/utils/__init__.py +0 -6
  139. webscout/Litlogger/utils/detectors.py +0 -153
  140. webscout/Litlogger/utils/formatters.py +0 -200
  141. webscout/Provider/ChatGPTGratis.py +0 -194
  142. webscout/Provider/TTI/AiForce/README.md +0 -159
  143. webscout/Provider/TTI/AiForce/__init__.py +0 -22
  144. webscout/Provider/TTI/AiForce/async_aiforce.py +0 -224
  145. webscout/Provider/TTI/AiForce/sync_aiforce.py +0 -245
  146. webscout/Provider/TTI/FreeAIPlayground/README.md +0 -99
  147. webscout/Provider/TTI/FreeAIPlayground/__init__.py +0 -9
  148. webscout/Provider/TTI/FreeAIPlayground/async_freeaiplayground.py +0 -181
  149. webscout/Provider/TTI/FreeAIPlayground/sync_freeaiplayground.py +0 -180
  150. webscout/Provider/TTI/ImgSys/README.md +0 -174
  151. webscout/Provider/TTI/ImgSys/__init__.py +0 -23
  152. webscout/Provider/TTI/ImgSys/async_imgsys.py +0 -202
  153. webscout/Provider/TTI/ImgSys/sync_imgsys.py +0 -195
  154. webscout/Provider/TTI/MagicStudio/README.md +0 -101
  155. webscout/Provider/TTI/MagicStudio/__init__.py +0 -2
  156. webscout/Provider/TTI/MagicStudio/async_magicstudio.py +0 -111
  157. webscout/Provider/TTI/MagicStudio/sync_magicstudio.py +0 -109
  158. webscout/Provider/TTI/Nexra/README.md +0 -155
  159. webscout/Provider/TTI/Nexra/__init__.py +0 -22
  160. webscout/Provider/TTI/Nexra/async_nexra.py +0 -286
  161. webscout/Provider/TTI/Nexra/sync_nexra.py +0 -258
  162. webscout/Provider/TTI/PollinationsAI/README.md +0 -146
  163. webscout/Provider/TTI/PollinationsAI/__init__.py +0 -23
  164. webscout/Provider/TTI/PollinationsAI/async_pollinations.py +0 -311
  165. webscout/Provider/TTI/PollinationsAI/sync_pollinations.py +0 -265
  166. webscout/Provider/TTI/aiarta/README.md +0 -134
  167. webscout/Provider/TTI/aiarta/__init__.py +0 -2
  168. webscout/Provider/TTI/aiarta/async_aiarta.py +0 -482
  169. webscout/Provider/TTI/aiarta/sync_aiarta.py +0 -440
  170. webscout/Provider/TTI/artbit/README.md +0 -100
  171. webscout/Provider/TTI/artbit/__init__.py +0 -22
  172. webscout/Provider/TTI/artbit/async_artbit.py +0 -155
  173. webscout/Provider/TTI/artbit/sync_artbit.py +0 -148
  174. webscout/Provider/TTI/fastflux/README.md +0 -129
  175. webscout/Provider/TTI/fastflux/__init__.py +0 -22
  176. webscout/Provider/TTI/fastflux/async_fastflux.py +0 -261
  177. webscout/Provider/TTI/fastflux/sync_fastflux.py +0 -252
  178. webscout/Provider/TTI/huggingface/README.md +0 -114
  179. webscout/Provider/TTI/huggingface/__init__.py +0 -22
  180. webscout/Provider/TTI/huggingface/async_huggingface.py +0 -199
  181. webscout/Provider/TTI/huggingface/sync_huggingface.py +0 -195
  182. webscout/Provider/TTI/piclumen/README.md +0 -161
  183. webscout/Provider/TTI/piclumen/__init__.py +0 -23
  184. webscout/Provider/TTI/piclumen/async_piclumen.py +0 -268
  185. webscout/Provider/TTI/piclumen/sync_piclumen.py +0 -233
  186. webscout/Provider/TTI/pixelmuse/README.md +0 -79
  187. webscout/Provider/TTI/pixelmuse/__init__.py +0 -4
  188. webscout/Provider/TTI/pixelmuse/async_pixelmuse.py +0 -249
  189. webscout/Provider/TTI/pixelmuse/sync_pixelmuse.py +0 -182
  190. webscout/Provider/TTI/talkai/README.md +0 -139
  191. webscout/Provider/TTI/talkai/__init__.py +0 -4
  192. webscout/Provider/TTI/talkai/async_talkai.py +0 -229
  193. webscout/Provider/TTI/talkai/sync_talkai.py +0 -207
  194. webscout/Provider/UNFINISHED/oivscode.py +0 -351
  195. webscout-8.2.8.dist-info/RECORD +0 -334
  196. {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/licenses/LICENSE.md +0 -0
  197. {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/top_level.txt +0 -0
@@ -1,121 +1,282 @@
1
1
  """
2
- OpenAI-Compatible API Server for Webscout
2
+ Webscout OpenAI-Compatible API Server
3
3
 
4
- This module provides an OpenAI-compatible API server that allows using
5
- various AI providers through a standardized interface compatible with
6
- OpenAI's API. This enables using Webscout providers with any tool or
7
- application designed to work with OpenAI's API.
8
-
9
- Usage:
10
- # From command line:
11
- python -m webscout.Provider.OPENAI.api --port 8080 --api-key "your-key"
12
-
13
- # From Python code:
14
- from webscout.Provider.OPENAI.api import start_server
15
- start_server(port=8080, api_key="your-key")
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.
16
7
  """
17
8
 
18
9
  from __future__ import annotations
19
10
 
20
- import logging
21
11
  import json
22
- import uvicorn
23
- import secrets
12
+ import logging
24
13
  import os
25
- import uuid
14
+ import secrets
15
+ import sys
26
16
  import time
27
- from pathlib import Path
28
- from typing import List, Dict, Optional, Union, Any, Generator
29
- from fastapi import FastAPI, Response, Request, Depends
30
- from fastapi.middleware.cors import CORSMiddleware
31
- from fastapi.responses import StreamingResponse, RedirectResponse, HTMLResponse, JSONResponse, FileResponse
32
- from fastapi.staticfiles import StaticFiles
17
+ import uuid
18
+ import inspect
19
+ import re
20
+ import codecs
21
+ from typing import List, Dict, Optional, Union, Any, Generator, Callable
22
+ import types
33
23
 
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
34
30
  from fastapi.exceptions import RequestValidationError
35
31
  from fastapi.security import APIKeyHeader
36
- from starlette.exceptions import HTTPException
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)
37
45
  from starlette.status import (
38
- HTTP_200_OK,
39
- HTTP_422_UNPROCESSABLE_ENTITY,
46
+ HTTP_422_UNPROCESSABLE_ENTITY,
40
47
  HTTP_404_NOT_FOUND,
41
48
  HTTP_401_UNAUTHORIZED,
42
49
  HTTP_403_FORBIDDEN,
43
50
  HTTP_500_INTERNAL_SERVER_ERROR,
44
51
  )
45
- from fastapi.encoders import jsonable_encoder
46
- from pydantic import BaseModel, Field
47
- from typing import List, Optional, Literal, Union
52
+
53
+ from webscout.Provider.OPENAI.pydantic_imports import BaseModel, Field
54
+ from typing import Literal
48
55
 
49
56
  # Import provider classes from the OPENAI directory
50
57
  from webscout.Provider.OPENAI import *
51
58
  from webscout.Provider.OPENAI.utils import (
52
- ChatCompletionChunk, ChatCompletion, Choice, ChoiceDelta,
53
- ChatCompletionMessage, CompletionUsage
59
+ ChatCompletion, Choice, ChatCompletionMessage, CompletionUsage
54
60
  )
55
61
 
56
- logger = logging.getLogger(__name__)
57
62
 
63
+ # Configuration constants
58
64
  DEFAULT_PORT = 8000
65
+ DEFAULT_HOST = "0.0.0.0"
66
+ API_VERSION = "v1"
67
+
68
+ # Setup logging
69
+ logging.basicConfig(
70
+ level=logging.INFO,
71
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
72
+ handlers=[
73
+ logging.StreamHandler(sys.stdout),
74
+ ]
75
+ )
76
+ logger = logging.getLogger("webscout.api")
77
+
78
+
79
+ class ServerConfig:
80
+ """Centralized configuration management for the API server."""
81
+
82
+ def __init__(self):
83
+ self.api_key: Optional[str] = None
84
+ self.provider_map: Dict[str, Any] = {}
85
+ self.default_provider: str = "ChatGPT"
86
+ self.base_url: Optional[str] = None
87
+ self.host: str = DEFAULT_HOST
88
+ self.port: int = DEFAULT_PORT
89
+ self.debug: bool = False
90
+ self.cors_origins: List[str] = ["*"]
91
+ self.max_request_size: int = 10 * 1024 * 1024 # 10MB
92
+ self.request_timeout: int = 300 # 5 minutes
93
+
94
+ def update(self, **kwargs) -> None:
95
+ """Update configuration with provided values."""
96
+ for key, value in kwargs.items():
97
+ if hasattr(self, key) and value is not None:
98
+ setattr(self, key, value)
99
+ logger.info(f"Config updated: {key} = {value}")
100
+
101
+ def validate(self) -> None:
102
+ """Validate configuration settings."""
103
+ if self.port < 1 or self.port > 65535:
104
+ raise ValueError(f"Invalid port number: {self.port}")
105
+
106
+ if self.default_provider not in self.provider_map and self.provider_map:
107
+ available_providers = list(set(v.__name__ for v in self.provider_map.values()))
108
+ logger.warning(f"Default provider '{self.default_provider}' not found. Available: {available_providers}")
109
+
110
+
111
+ # Global configuration instance
112
+ config = ServerConfig()
113
+
114
+
115
+ # Define Pydantic models for multimodal content parts, aligning with OpenAI's API
116
+ class TextPart(BaseModel):
117
+ """Text content part for multimodal messages."""
118
+ type: Literal["text"]
119
+ text: str
120
+
121
+
122
+ class ImageURL(BaseModel):
123
+ """Image URL configuration for multimodal messages."""
124
+ url: str # Can be http(s) or data URI
125
+ detail: Optional[Literal["auto", "low", "high"]] = Field(
126
+ "auto",
127
+ description="Specifies the detail level of the image."
128
+ )
129
+
130
+
131
+ class ImagePart(BaseModel):
132
+ """Image content part for multimodal messages."""
133
+ type: Literal["image_url"]
134
+ image_url: ImageURL
135
+
136
+
137
+ MessageContentParts = Union[TextPart, ImagePart]
138
+
59
139
 
60
140
  class Message(BaseModel):
141
+ """Chat message model compatible with OpenAI API."""
61
142
  role: Literal["system", "user", "assistant", "function", "tool"]
62
- content: str
143
+ content: Optional[Union[str, List[MessageContentParts]]] = Field(
144
+ None,
145
+ description="The content of the message. Can be a string, a list of content parts (for multimodal), or null."
146
+ )
63
147
  name: Optional[str] = None
148
+ # Future: Add tool_calls and tool_call_id for function calling support
149
+ # tool_calls: Optional[List[ToolCall]] = None
150
+ # tool_call_id: Optional[str] = None
64
151
 
65
152
  class ChatCompletionRequest(BaseModel):
66
- model: str
67
- messages: List[Message]
68
- temperature: Optional[float] = None
69
- top_p: Optional[float] = None
70
- n: Optional[int] = 1
71
- stream: Optional[bool] = False
72
- max_tokens: Optional[int] = None
73
- presence_penalty: Optional[float] = None
74
- frequency_penalty: Optional[float] = None
75
- logit_bias: Optional[Dict[str, float]] = None
76
- user: Optional[str] = None
77
- stop: Optional[Union[str, List[str]]] = None
78
-
153
+ model: str = Field(..., description="ID of the model to use. See the model endpoint for the available models.")
154
+ messages: List[Message] = Field(..., description="A list of messages comprising the conversation so far.")
155
+ 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.")
156
+ 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.")
157
+ n: Optional[int] = Field(1, description="How many chat completion choices to generate for each input message.")
158
+ 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.")
159
+ max_tokens: Optional[int] = Field(None, description="The maximum number of tokens to generate in the chat completion.")
160
+ 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.")
161
+ 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.")
162
+ logit_bias: Optional[Dict[str, float]] = Field(None, description="Modify the likelihood of specified tokens appearing in the completion.")
163
+ user: Optional[str] = Field(None, description="A unique identifier representing your end-user, which can help the API to monitor and detect abuse.")
164
+ stop: Optional[Union[str, List[str]]] = Field(None, description="Up to 4 sequences where the API will stop generating further tokens.")
165
+
79
166
  class Config:
80
167
  extra = "ignore" # Ignore extra fields that aren't in the model
168
+ schema_extra = {
169
+ "example": {
170
+ "model": "ChatGPT/gpt-4o",
171
+ "messages": [
172
+ {"role": "system", "content": "You are a helpful assistant."},
173
+ {"role": "user", "content": "Hello, how are you?"}
174
+ ],
175
+ "temperature": 0.7,
176
+ "max_tokens": 150,
177
+ "stream": False
178
+ }
179
+ }
180
+
181
+ class ModelInfo(BaseModel):
182
+ """Model information for the models endpoint."""
183
+ id: str
184
+ object: str = "model"
185
+ created: int
186
+ owned_by: str
187
+
81
188
 
82
189
  class ModelListResponse(BaseModel):
190
+ """Response model for the models list endpoint."""
83
191
  object: str = "list"
84
- data: List[Dict[str, Any]]
192
+ data: List[ModelInfo]
85
193
 
86
- class ErrorResponse(Response):
87
- media_type = "application/json"
88
194
 
89
- @classmethod
90
- def from_exception(cls, exception: Exception, status_code: int = HTTP_500_INTERNAL_SERVER_ERROR):
91
- return cls(format_exception(exception), status_code)
195
+ class ErrorDetail(BaseModel):
196
+ """Error detail structure compatible with OpenAI API."""
197
+ message: str
198
+ type: str = "server_error"
199
+ param: Optional[str] = None
200
+ code: Optional[str] = None
92
201
 
93
- @classmethod
94
- def from_message(cls, message: str, status_code: int = HTTP_500_INTERNAL_SERVER_ERROR, headers: dict = None):
95
- return cls(format_exception(message), status_code, headers=headers)
96
202
 
97
- def render(self, content) -> bytes:
98
- return str(content).encode(errors="ignore")
203
+ class ErrorResponse(BaseModel):
204
+ """Error response structure compatible with OpenAI API."""
205
+ error: ErrorDetail
206
+
207
+
208
+ class APIError(Exception):
209
+ """Custom exception for API errors."""
210
+
211
+ def __init__(self, message: str, status_code: int = HTTP_500_INTERNAL_SERVER_ERROR,
212
+ error_type: str = "server_error", param: Optional[str] = None,
213
+ code: Optional[str] = None):
214
+ self.message = message
215
+ self.status_code = status_code
216
+ self.error_type = error_type
217
+ self.param = param
218
+ self.code = code
219
+ super().__init__(message)
220
+
221
+ def to_response(self) -> JSONResponse:
222
+ """Convert to FastAPI JSONResponse."""
223
+ error_detail = ErrorDetail(
224
+ message=self.message,
225
+ type=self.error_type,
226
+ param=self.param,
227
+ code=self.code
228
+ )
229
+ error_response = ErrorResponse(error=error_detail)
230
+ return JSONResponse(
231
+ status_code=self.status_code,
232
+ content=error_response.model_dump(exclude_none=True)
233
+ )
234
+
99
235
 
100
236
  class AppConfig:
237
+ """Legacy configuration class for backward compatibility."""
101
238
  api_key: Optional[str] = None
102
239
  provider_map = {}
103
240
  default_provider = "ChatGPT"
241
+ base_url: Optional[str] = None
104
242
 
105
243
  @classmethod
106
244
  def set_config(cls, **data):
245
+ """Set configuration values."""
107
246
  for key, value in data.items():
108
247
  setattr(cls, key, value)
248
+ # Sync with new config system
249
+ config.update(**data)
250
+
251
+ # Custom route class to handle dynamic base URLs
252
+ # Note: The /docs 404 issue is likely related to server execution (Werkzeug logs vs. Uvicorn script).
253
+ # This DynamicBaseRoute, when AppConfig.base_url is None, should act as a passthrough and not break /docs.
254
+ # If AppConfig.base_url is set, this route class has limitations in correctly handling prefixed routes
255
+ # without more complex path manipulation or using FastAPI's APIRouter prefixing/mounting features.
256
+ class DynamicBaseRoute(APIRoute):
257
+ def get_route_handler(self) -> Callable:
258
+ original_route_handler = super().get_route_handler()
259
+ async def custom_route_handler(request: Request) -> Response:
260
+ if AppConfig.base_url:
261
+ if not request.url.path.startswith(AppConfig.base_url):
262
+ # This logic might need refinement if base_url is used.
263
+ # For API routes not matching the prefix, a 404 might be appropriate.
264
+ # Docs routes (/docs, /openapi.json) are usually at the root.
265
+ # The current 'pass' allows root docs even if base_url is set for APIs.
266
+ pass
267
+ return await original_route_handler(request)
268
+ return custom_route_handler
109
269
 
110
270
  def create_app():
111
271
  app = FastAPI(
112
272
  title="Webscout OpenAI API",
113
273
  description="OpenAI API compatible interface for various LLM providers",
114
274
  version="0.1.0",
115
- docs_url=None,
275
+ docs_url="/docs",
276
+ redoc_url="/redoc",
277
+ openapi_url="/openapi.json",
116
278
  )
117
-
118
- # Add CORS middleware to allow cross-origin requests
279
+ app.router.route_class = DynamicBaseRoute
119
280
  app.add_middleware(
120
281
  CORSMiddleware,
121
282
  allow_origins=["*"],
@@ -123,65 +284,121 @@ def create_app():
123
284
  allow_methods=["*"],
124
285
  allow_headers=["*"],
125
286
  )
126
-
127
287
  api = Api(app)
128
288
  api.register_authorization()
129
- api.register_json_middleware() # Add custom JSON middleware
130
289
  api.register_validation_exception_handler()
131
290
  api.register_routes()
132
-
133
- # Initialize provider map
134
291
  initialize_provider_map()
135
-
292
+
293
+ def custom_openapi():
294
+ if app.openapi_schema:
295
+ return app.openapi_schema
296
+
297
+ openapi_schema = get_openapi(
298
+ title=app.title,
299
+ version=app.version,
300
+ description=app.description,
301
+ routes=app.routes,
302
+ )
303
+
304
+ if "components" not in openapi_schema: openapi_schema["components"] = {}
305
+ if "schemas" not in openapi_schema["components"]: openapi_schema["components"]["schemas"] = {}
306
+
307
+ # Use Pydantic's schema generation for accuracy
308
+ # Assuming Pydantic v1 .schema() or v2 .model_json_schema() based on pydantic_imports
309
+ # For broader compatibility, trying .schema() first.
310
+ # If using Pydantic v2 primarily, .model_json_schema() is preferred.
311
+ schema_method_name = "model_json_schema" if hasattr(BaseModel, "model_json_schema") else "schema"
312
+
313
+ # Add/update schemas derived from Pydantic models to ensure they are correctly defined
314
+ pydantic_models_to_register = {
315
+ "TextPart": TextPart,
316
+ "ImageURL": ImageURL,
317
+ "ImagePart": ImagePart,
318
+ "Message": Message,
319
+ "ChatCompletionRequest": ChatCompletionRequest,
320
+ }
321
+
322
+ for name, model_cls in pydantic_models_to_register.items():
323
+ if schema_method_name == "model_json_schema":
324
+ schema_data = model_cls.model_json_schema(ref_template="#/components/schemas/{model}")
325
+ else:
326
+ schema_data = model_cls.schema()
327
+ # Pydantic might add a "title" to the schema, which is often not desired for component schemas
328
+ if "title" in schema_data:
329
+ del schema_data["title"]
330
+ openapi_schema["components"]["schemas"][name] = schema_data
331
+
332
+ app.openapi_schema = openapi_schema
333
+ return app.openapi_schema
334
+
335
+ app.openapi = custom_openapi
136
336
  return app
137
337
 
138
338
  def create_app_debug():
139
- logging.basicConfig(level=logging.DEBUG)
140
339
  return create_app()
141
340
 
142
- def initialize_provider_map():
143
- """Initialize the provider map with available provider classes"""
144
- import sys
145
- import inspect
146
- from webscout.Provider.OPENAI.base import OpenAICompatibleProvider
147
-
148
- # Get all imported modules from OPENAI package
149
- module = sys.modules["webscout.Provider.OPENAI"]
150
-
151
- # Find all provider classes (subclasses of OpenAICompatibleProvider)
152
- for name, obj in inspect.getmembers(module):
153
- if inspect.isclass(obj) and issubclass(obj, OpenAICompatibleProvider) and obj.__name__ != "OpenAICompatibleProvider":
154
- # Register the provider class by its name
155
- AppConfig.provider_map[obj.__name__] = obj
156
- logger.info(f"Registered provider: {obj.__name__}")
157
-
158
- # Also add additional mappings for model names
159
- if hasattr(obj, "AVAILABLE_MODELS") and isinstance(obj.AVAILABLE_MODELS, (list, tuple, set)):
160
- for model in obj.AVAILABLE_MODELS:
161
- if model and isinstance(model, str) and model != obj.__name__:
162
- AppConfig.provider_map[model] = obj
163
- logger.info(f"Mapped model {model} to provider {obj.__name__}")
164
-
165
- # If no providers were found, add a fallback for testing
166
- if not AppConfig.provider_map:
167
- logger.warning("No providers found, using ChatGPT as fallback")
168
- from webscout.Provider.OPENAI.chatgpt import ChatGPT
169
- AppConfig.provider_map["ChatGPT"] = ChatGPT
170
- AppConfig.provider_map["gpt-4"] = ChatGPT
171
- AppConfig.provider_map["gpt-4o"] = ChatGPT
172
- AppConfig.provider_map["gpt-4o-mini"] = ChatGPT
173
- AppConfig.default_provider = "ChatGPT"
174
-
175
- # Get distinct provider names
176
- provider_names = list(set(v.__name__ for v in AppConfig.provider_map.values()))
177
-
178
- # Get model names (excluding provider class names)
179
- provider_class_names = set(v.__name__ for v in AppConfig.provider_map.values())
180
- model_names = [model for model in AppConfig.provider_map.keys() if model not in provider_class_names]
181
-
182
- logger.info(f"Available providers ({len(provider_names)}): {provider_names}")
183
- logger.info(f"Available models ({len(model_names)}): {sorted(model_names)}")
184
- logger.info(f"Default provider: {AppConfig.default_provider}")
341
+ def initialize_provider_map() -> None:
342
+ """Initialize the provider map by discovering available providers."""
343
+ logger.info("Initializing provider map...")
344
+
345
+ try:
346
+ from webscout.Provider.OPENAI.base import OpenAICompatibleProvider
347
+ module = sys.modules["webscout.Provider.OPENAI"]
348
+
349
+ provider_count = 0
350
+ model_count = 0
351
+
352
+ for name, obj in inspect.getmembers(module):
353
+ if (
354
+ inspect.isclass(obj)
355
+ and issubclass(obj, OpenAICompatibleProvider)
356
+ and obj.__name__ != "OpenAICompatibleProvider"
357
+ ):
358
+ provider_name = obj.__name__
359
+ AppConfig.provider_map[provider_name] = obj
360
+ config.provider_map[provider_name] = obj
361
+ provider_count += 1
362
+
363
+ # Register available models for this provider
364
+ if hasattr(obj, "AVAILABLE_MODELS") and isinstance(
365
+ obj.AVAILABLE_MODELS, (list, tuple, set)
366
+ ):
367
+ for model in obj.AVAILABLE_MODELS:
368
+ if model and isinstance(model, str):
369
+ model_key = f"{provider_name}/{model}"
370
+ AppConfig.provider_map[model_key] = obj
371
+ config.provider_map[model_key] = obj
372
+ model_count += 1
373
+
374
+ # Fallback to ChatGPT if no providers found
375
+ if not AppConfig.provider_map:
376
+ logger.warning("No providers found, using ChatGPT fallback")
377
+ try:
378
+ from webscout.Provider.OPENAI.chatgpt import ChatGPT
379
+ fallback_models = ["gpt-4", "gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"]
380
+
381
+ AppConfig.provider_map["ChatGPT"] = ChatGPT
382
+ config.provider_map["ChatGPT"] = ChatGPT
383
+
384
+ for model in fallback_models:
385
+ model_key = f"ChatGPT/{model}"
386
+ AppConfig.provider_map[model_key] = ChatGPT
387
+ config.provider_map[model_key] = ChatGPT
388
+
389
+ AppConfig.default_provider = "ChatGPT"
390
+ config.default_provider = "ChatGPT"
391
+ provider_count = 1
392
+ model_count = len(fallback_models)
393
+ except ImportError as e:
394
+ logger.error(f"Failed to import ChatGPT fallback: {e}")
395
+ raise APIError("No providers available", HTTP_500_INTERNAL_SERVER_ERROR)
396
+
397
+ logger.info(f"Initialized {provider_count} providers with {model_count} models")
398
+
399
+ except Exception as e:
400
+ logger.error(f"Failed to initialize provider map: {e}")
401
+ raise APIError(f"Provider initialization failed: {e}", HTTP_500_INTERNAL_SERVER_ERROR)
185
402
 
186
403
  class Api:
187
404
  def __init__(self, app: FastAPI) -> None:
@@ -194,522 +411,498 @@ class Api:
194
411
  if AppConfig.api_key is not None:
195
412
  auth_header = await self.get_api_key(request)
196
413
  path = request.url.path
197
- if path.startswith("/v1"):
414
+ if path.startswith("/v1"): # Only protect /v1 routes
415
+ # Also allow access to /docs, /openapi.json etc. if AppConfig.base_url is not set or path is not under it
416
+ # This logic should be fine as it only protects /v1 paths
198
417
  if auth_header is None:
199
418
  return ErrorResponse.from_message("API key required", HTTP_401_UNAUTHORIZED)
200
- # Strip "Bearer " prefix if present
201
419
  if auth_header.startswith("Bearer "):
202
420
  auth_header = auth_header[7:]
203
- if AppConfig.api_key is None or not secrets.compare_digest(AppConfig.api_key, auth_header):
421
+ 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
204
422
  return ErrorResponse.from_message("Invalid API key", HTTP_403_FORBIDDEN)
205
423
  return await call_next(request)
206
424
 
207
- def register_json_middleware(self):
208
- @self.app.middleware("http")
209
- async def parse_json_middleware(request: Request, call_next):
210
- if request.method == "POST" and "/v1/chat/completions" in request.url.path:
211
- try:
212
- # Try parsing the JSON body manually first to catch JSON errors early
213
- body = await request.body()
214
- if body:
215
- body_str = body.decode('utf-8', errors='ignore')
216
- original_body = body_str
217
- logger.debug(f"Original request body: {body_str}")
218
-
219
- # PowerShell with curl often has formatting issues with JSON
220
- try:
221
- # First try normal JSON parsing
222
- json.loads(body_str)
223
- logger.debug("JSON parsed successfully")
224
- except json.JSONDecodeError as e:
225
- logger.warning(f"JSON parse error, attempting fixes: {str(e)}")
226
-
227
- # Series of fixes to try for common PowerShell JSON issues
228
- try:
229
- # Fix 1: Try to clean up the JSON string
230
- # Replace literal backslash+quote with just quote
231
- body_str = body_str.replace("\\\"", "\"")
232
- # Add double quotes to unquoted property names and string values
233
- # This is a common issue with PowerShell's curl
234
- import re
235
-
236
- # Try a full JSON correction - replace single quotes with double quotes
237
- # This is a more aggressive fix that might work in simple cases
238
- fixed_body = body_str.replace("'", "\"")
239
- try:
240
- json.loads(fixed_body)
241
- body_str = fixed_body
242
- logger.info("Fixed JSON by replacing single quotes with double quotes")
243
- except json.JSONDecodeError:
244
- # If that didn't work, try more sophisticated fixes
245
- pass
246
-
247
- # Check for missing quotes around property names
248
- # Look for patterns like {model: instead of {"model":
249
- body_str = re.sub(r'\{([^"\s][^:\s]*)(\s*:)', r'{"\1"\2', body_str)
250
- body_str = re.sub(r',\s*([^"\s][^:\s]*)(\s*:)', r', "\1"\2', body_str)
251
-
252
- # Try to parse with the fixed body
253
- json.loads(body_str)
254
- # If successful, modify the request._body for downstream processing
255
- logger.info(f"Successfully fixed JSON format\nOriginal: {original_body}\nFixed: {body_str}")
256
- request._body = body_str.encode('utf-8')
257
- except Exception as fix_error:
258
- logger.error(f"Failed to fix JSON: {str(fix_error)}")
259
-
260
- # Let's return a helpful error message with the proper format example
261
- example = json.dumps({
262
- "model": "gpt-4",
263
- "messages": [{"role": "user", "content": "Hello"}]
264
- })
265
- return JSONResponse(
266
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
267
- content=jsonable_encoder({
268
- "detail": [
269
- {
270
- "loc": ["body", 0],
271
- "message": f"Invalid JSON format: {str(e)}. Make sure to use double quotes for both keys and values. Example: {example}",
272
- "type": "json_invalid"
273
- }
274
- ]
275
- }),
276
- )
277
- except Exception as e:
278
- error_detail = str(e)
279
- logger.error(f"Request processing error: {error_detail}")
280
- return JSONResponse(
281
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
282
- content=jsonable_encoder({
283
- "detail": [
284
- {
285
- "loc": ["body", 0],
286
- "message": f"Request processing error: {error_detail}",
287
- "type": "request_invalid"
288
- }
289
- ]
290
- }),
291
- )
292
- return await call_next(request)
293
-
294
425
  def register_validation_exception_handler(self):
426
+ """Register comprehensive exception handlers."""
427
+
428
+ @self.app.exception_handler(APIError)
429
+ async def api_error_handler(request: Request, exc: APIError):
430
+ """Handle custom API errors."""
431
+ logger.error(f"API Error: {exc.message} (Status: {exc.status_code})")
432
+ return exc.to_response()
433
+
295
434
  @self.app.exception_handler(RequestValidationError)
296
435
  async def validation_exception_handler(request: Request, exc: RequestValidationError):
297
- details = exc.errors()
298
- modified_details = []
299
- for error in details:
300
- modified_details.append({
301
- "loc": error["loc"],
302
- "message": error["msg"],
303
- "type": error["type"],
304
- })
436
+ errors = exc.errors()
437
+ error_messages = []
438
+ body = await request.body()
439
+ is_empty_body = not body or body.strip() in (b"", b"null", b"{}")
440
+ for error in errors:
441
+ loc = error.get("loc", [])
442
+ # Ensure loc_str is user-friendly
443
+ loc_str_parts = []
444
+ for item in loc:
445
+ if item == "body": # Skip "body" part if it's the first element of a longer path
446
+ if len(loc) > 1: continue
447
+ loc_str_parts.append(str(item))
448
+ loc_str = " -> ".join(loc_str_parts)
449
+
450
+ msg = error.get("msg", "Validation error")
451
+
452
+ # Check if this error is for the 'content' field specifically due to multimodal input
453
+ if len(loc) >=3 and loc[0] == 'body' and loc[1] == 'messages' and loc[-1] == 'content':
454
+ # Check if the error type suggests a string was expected but a list (or vice-versa) was given for content
455
+ if "Input should be a valid string" in msg and error.get("input_type") == "list":
456
+ error_messages.append({
457
+ "loc": loc,
458
+ "message": f"Invalid message content: {msg}. Ensure content matches the expected format (string or list of content parts). Path: {loc_str}",
459
+ "type": error.get("type", "validation_error")
460
+ })
461
+ continue # Skip default message formatting for this specific case
462
+ elif "Input should be a valid list" in msg and error.get("input_type") == "string":
463
+ error_messages.append({
464
+ "loc": loc,
465
+ "message": f"Invalid message content: {msg}. Ensure content matches the expected format (string or list of content parts). Path: {loc_str}",
466
+ "type": error.get("type", "validation_error")
467
+ })
468
+ continue
469
+
470
+ if "body" in loc:
471
+ if len(loc) > 1 and loc[1] == "messages":
472
+ error_messages.append({
473
+ "loc": loc,
474
+ "message": "The 'messages' field is required and must be a non-empty array of message objects. " + f"Error: {msg} at {loc_str}",
475
+ "type": error.get("type", "validation_error")
476
+ })
477
+ elif len(loc) > 1 and loc[1] == "model":
478
+ error_messages.append({
479
+ "loc": loc,
480
+ "message": "The 'model' field is required and must be a string. " + f"Error: {msg} at {loc_str}",
481
+ "type": error.get("type", "validation_error")
482
+ })
483
+ else:
484
+ error_messages.append({
485
+ "loc": loc,
486
+ "message": f"{msg} at {loc_str}",
487
+ "type": error.get("type", "validation_error")
488
+ })
489
+ else:
490
+ error_messages.append({
491
+ "loc": loc,
492
+ "message": f"{msg} at {loc_str}",
493
+ "type": error.get("type", "validation_error")
494
+ })
495
+ if request.url.path == "/v1/chat/completions":
496
+ example = ChatCompletionRequest.Config.schema_extra["example"]
497
+ if is_empty_body:
498
+ return JSONResponse(
499
+ status_code=HTTP_422_UNPROCESSABLE_ENTITY,
500
+ content={
501
+ "error": {
502
+ "message": "Request body is required and must include 'model' and 'messages'.",
503
+ "type": "invalid_request_error",
504
+ "param": None,
505
+ "code": "body_missing"
506
+ },
507
+ "example": example
508
+ }
509
+ )
510
+ return JSONResponse(
511
+ status_code=HTTP_422_UNPROCESSABLE_ENTITY,
512
+ content={"detail": error_messages, "example": example}
513
+ )
305
514
  return JSONResponse(
306
515
  status_code=HTTP_422_UNPROCESSABLE_ENTITY,
307
- content=jsonable_encoder({"detail": modified_details}),
516
+ content={"detail": error_messages}
308
517
  )
309
-
310
- @self.app.exception_handler(HTTPException)
311
- async def http_exception_handler(request: Request, exc: HTTPException):
518
+ @self.app.exception_handler(StarletteHTTPException)
519
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
312
520
  return JSONResponse(
313
521
  status_code=exc.status_code,
314
- content=jsonable_encoder({"detail": exc.detail}),
522
+ content={"detail": exc.detail}
315
523
  )
316
-
317
- @self.app.exception_handler(json.JSONDecodeError)
318
- async def json_decode_error_handler(request: Request, exc: json.JSONDecodeError):
524
+ @self.app.exception_handler(Exception)
525
+ async def general_exception_handler(request: Request, exc: Exception):
319
526
  return JSONResponse(
320
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
321
- content=jsonable_encoder({
322
- "detail": [
323
- {
324
- "loc": ["body", 0],
325
- "message": f"Invalid JSON format: {str(exc)}",
326
- "type": "json_invalid"
327
- }
328
- ]
329
- }),
527
+ status_code=HTTP_500_INTERNAL_SERVER_ERROR,
528
+ content={"detail": f"Internal server error: {str(exc)}"}
330
529
  )
331
530
 
332
531
  def register_routes(self):
333
- @self.app.get("/")
334
- async def read_root(request: Request):
335
- return RedirectResponse(url="/docs")
336
-
337
- @self.app.get("/v1")
338
- async def read_root_v1(request: Request):
532
+ @self.app.get("/", include_in_schema=False)
533
+ async def root():
534
+ # Note: If /docs is 404ing, check if server is Uvicorn (expected) or Werkzeug (from logs).
535
+ # Werkzeug logs suggest possible execution of a Flask app or WSGI misconfiguration.
536
+ # This api.py file is intended for Uvicorn.
339
537
  return RedirectResponse(url="/docs")
340
538
 
341
- @self.app.get("/docs", include_in_schema=False)
342
- async def custom_swagger_ui(request: Request):
343
- from fastapi.openapi.docs import get_swagger_ui_html
344
- return get_swagger_ui_html(
345
- openapi_url=self.app.openapi_url,
346
- title=f"{self.app.title} - Swagger UI"
347
- )
348
-
349
- @self.app.get("/v1//models", include_in_schema=False) # Handle double slash case
350
- async def list_models_double_slash():
351
- """Redirect double slash models endpoint to the correct one"""
352
- return RedirectResponse(url="/v1/models")
353
-
354
- @self.app.get("/v1/models")
539
+ @self.app.get("/v1/models", response_model=ModelListResponse)
355
540
  async def list_models():
356
- """List available models"""
357
- from webscout.Provider.OPENAI.utils import ModelData, ModelList
358
- models_data = []
359
-
360
- # Get current timestamp
361
- created_time = int(time.time())
362
-
541
+ models = []
363
542
  for model_name, provider_class in AppConfig.provider_map.items():
364
- if not hasattr(provider_class, "AVAILABLE_MODELS") or model_name in provider_class.AVAILABLE_MODELS:
365
- # Create a more detailed model data object with proper fields
366
- model = ModelData(
367
- id=model_name,
368
- created=created_time,
369
- owned_by=getattr(provider_class, "__name__", "webscout"),
370
- permission=[{
371
- "id": f"modelperm-{model_name}",
372
- "object": "model_permission",
373
- "created": created_time,
374
- "allow_create_engine": False,
375
- "allow_sampling": True,
376
- "allow_logprobs": True,
377
- "allow_search_indices": hasattr(provider_class, "supports_embeddings") and provider_class.supports_embeddings,
378
- "allow_view": True,
379
- "allow_fine_tuning": False,
380
- "organization": "*",
381
- "group": None,
382
- "is_blocking": False
383
- }]
384
- )
385
- models_data.append(model)
386
-
387
- # Return as ModelList for proper formatting
388
- response = ModelList(data=models_data)
389
- return response.to_dict()
390
-
391
- @self.app.get("/v1/models/{model_name}")
392
- async def get_model(model_name: str):
393
- """Get information about a specific model"""
394
- from webscout.Provider.OPENAI.utils import ModelData
395
- created_time = int(time.time())
396
-
397
- # Check if the model exists in our provider map
398
- if model_name in AppConfig.provider_map:
399
- provider_class = AppConfig.provider_map[model_name]
400
-
401
- # Create a proper OpenAI-compatible model response
402
- model = ModelData(
403
- id=model_name,
404
- created=created_time,
405
- owned_by=getattr(provider_class, "__name__", "webscout"),
406
- permission=[{
407
- "id": f"modelperm-{model_name}",
408
- "object": "model_permission",
409
- "created": created_time,
410
- "allow_create_engine": False,
411
- "allow_sampling": True,
412
- "allow_logprobs": True,
413
- "allow_search_indices": hasattr(provider_class, "supports_embeddings") and provider_class.supports_embeddings,
414
- "allow_view": True,
415
- "allow_fine_tuning": False,
416
- "organization": "*",
417
- "group": None,
418
- "is_blocking": False
419
- }]
420
- )
421
- return model.to_dict()
422
-
423
- # If we reached here, the model was not found
424
- return ErrorResponse.from_message(f"Model '{model_name}' not found", HTTP_404_NOT_FOUND)
425
-
426
- @self.app.post("/v1/chat/completions")
427
- async def chat_completions(request: Request):
428
- """Create a chat completion"""
429
- # First manually extract the request body to better handle parsing errors
430
- try:
431
- # Note: We don't need to parse JSON here as our middleware already handles that
432
- # and fixes PowerShell JSON issues
433
- body = await request.json()
434
- logger.debug(f"Request body parsed successfully: {body}")
435
-
436
- # Check for required fields
437
- if "model" not in body:
438
- return JSONResponse(
439
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
440
- content=jsonable_encoder({
441
- "detail": [
442
- {
443
- "loc": ["body", "model"],
444
- "message": "Field 'model' is required",
445
- "type": "missing"
446
- }
447
- ]
448
- }),
449
- )
450
-
451
- if "messages" not in body or not isinstance(body["messages"], list) or len(body["messages"]) == 0:
452
- return JSONResponse(
453
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
454
- content=jsonable_encoder({
455
- "detail": [
456
- {
457
- "loc": ["body", "messages"],
458
- "message": "Field 'messages' must be a non-empty array",
459
- "type": "missing"
460
- }
461
- ]
462
- }),
463
- )
464
-
465
- # Now parse it through Pydantic model
466
- try:
467
- chat_request = ChatCompletionRequest(**body)
468
- except Exception as validation_error:
469
- logger.warning(f"Validation error: {validation_error}")
470
- # Try to provide helpful error messages for common validation issues
471
- error_msg = str(validation_error)
472
- if "role" in error_msg:
473
- return JSONResponse(
474
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
475
- content=jsonable_encoder({
476
- "detail": [
477
- {
478
- "loc": ["body", "messages", 0, "role"],
479
- "message": "Each message must have a 'role' field with one of these values: 'system', 'user', 'assistant'",
480
- "type": "value_error"
481
- }
482
- ]
483
- }),
484
- )
485
- elif "content" in error_msg:
486
- return JSONResponse(
487
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
488
- content=jsonable_encoder({
489
- "detail": [
490
- {
491
- "loc": ["body", "messages", 0, "content"],
492
- "message": "Each message must have a 'content' field with string value",
493
- "type": "value_error"
494
- }
495
- ]
496
- }),
497
- )
498
- else:
499
- return JSONResponse(
500
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
501
- content=jsonable_encoder({
502
- "detail": [
503
- {
504
- "loc": ["body"],
505
- "message": f"Validation error: {error_msg}",
506
- "type": "value_error"
507
- }
508
- ]
509
- }),
510
- )
511
-
512
- except json.JSONDecodeError as e:
513
- logger.error(f"JSON decode error in chat_completions: {e}")
514
- example = json.dumps({
515
- "model": "gpt-4",
516
- "messages": [{"role": "user", "content": "Hello"}]
543
+ if "/" not in model_name:
544
+ continue # Skip provider names
545
+ if any(m["id"] == model_name for m in models):
546
+ continue
547
+ models.append({
548
+ "id": model_name,
549
+ "object": "model",
550
+ "created": int(time.time()),
551
+ "owned_by": provider_class.__name__
517
552
  })
518
- return JSONResponse(
519
- status_code=HTTP_422_UNPROCESSABLE_ENTITY,
520
- content=jsonable_encoder({
521
- "detail": [
522
- {
523
- "loc": ["body", 0],
524
- "message": f"Invalid JSON format: {str(e)}. Example of correct format: {example}",
525
- "type": "json_invalid"
526
- }
527
- ]
528
- }),
529
- )
530
- except Exception as e:
531
- logger.exception(f"Unexpected error in chat_completions: {e}")
532
- return ErrorResponse.from_message(
533
- f"Invalid request parameters: {str(e)}",
534
- HTTP_422_UNPROCESSABLE_ENTITY
535
- )
536
- """Create a chat completion"""
553
+ return {
554
+ "object": "list",
555
+ "data": models
556
+ }
557
+
558
+ @self.app.post(
559
+ "/v1/chat/completions",
560
+ response_model_exclude_none=True,
561
+ response_model_exclude_unset=True,
562
+ openapi_extra={ # This ensures the example is shown in docs
563
+ "requestBody": {
564
+ "content": {
565
+ "application/json": {
566
+ "schema": {
567
+ "$ref": "#/components/schemas/ChatCompletionRequest" # Relies on custom_openapi
568
+ },
569
+ "example": ChatCompletionRequest.Config.schema_extra["example"]
570
+ }
571
+ }
572
+ }
573
+ }
574
+ )
575
+ async def chat_completions(
576
+ chat_request: ChatCompletionRequest = Body(...)
577
+ ):
578
+ """Handle chat completion requests with comprehensive error handling."""
579
+ start_time = time.time()
580
+ request_id = f"chatcmpl-{uuid.uuid4()}"
581
+
537
582
  try:
538
- # Determine which provider to use based on the model
539
- provider_class = None
540
- model = chat_request.model
541
- logger.info(f"Chat completion request for model: {model}")
542
-
543
- if model in AppConfig.provider_map:
544
- provider_class = AppConfig.provider_map[model]
545
- logger.info(f"Found provider class for model {model}: {provider_class.__name__}")
546
- else:
547
- # Use default provider if specific provider not found
548
- provider_class = AppConfig.provider_map.get(AppConfig.default_provider)
549
- logger.info(f"Using default provider {AppConfig.default_provider} for model {model}")
550
-
551
- if not provider_class:
552
- logger.error(f"No provider available for model {model}. Available models: {list(AppConfig.provider_map.keys())}")
553
- return ErrorResponse.from_message(
554
- f"Model '{model}' not supported. Available models: {list(AppConfig.provider_map.keys())}",
555
- HTTP_404_NOT_FOUND
556
- )
583
+ logger.info(f"Processing chat completion request {request_id} for model: {chat_request.model}")
584
+
585
+ # Resolve provider and model
586
+ provider_class, model_name = resolve_provider_and_model(chat_request.model)
557
587
 
558
- # Initialize provider
559
- logger.info(f"Initializing provider {provider_class.__name__}")
588
+ # Initialize provider with error handling
560
589
  try:
561
590
  provider = provider_class()
591
+ logger.debug(f"Initialized provider: {provider_class.__name__}")
562
592
  except Exception as e:
563
- logger.exception(f"Failed to initialize provider {provider_class.__name__}: {e}")
564
- return ErrorResponse.from_message(
593
+ logger.error(f"Failed to initialize provider {provider_class.__name__}: {e}")
594
+ raise APIError(
565
595
  f"Failed to initialize provider {provider_class.__name__}: {e}",
566
- HTTP_500_INTERNAL_SERVER_ERROR
596
+ HTTP_500_INTERNAL_SERVER_ERROR,
597
+ "provider_error"
567
598
  )
568
599
 
569
- # Prepare completion parameters
570
- # Convert Message objects to dictionaries for the provider
571
- messages = []
572
- for msg in chat_request.messages:
573
- message_dict = {
574
- "role": msg.role,
575
- "content": msg.content
576
- }
577
- # Add name field if present
578
- if msg.name:
579
- message_dict["name"] = msg.name
580
- messages.append(message_dict)
581
-
582
- params = {
583
- "model": model,
584
- "messages": messages,
585
- "stream": chat_request.stream,
586
- }
600
+ # Process and validate messages
601
+ processed_messages = process_messages(chat_request.messages)
587
602
 
588
- # Add optional parameters if provided
589
- if chat_request.temperature is not None:
590
- params["temperature"] = chat_request.temperature
591
- if chat_request.max_tokens is not None:
592
- params["max_tokens"] = chat_request.max_tokens
593
- if chat_request.top_p is not None:
594
- params["top_p"] = chat_request.top_p
603
+ # Prepare parameters for provider
604
+ params = prepare_provider_params(chat_request, model_name, processed_messages)
595
605
 
596
- # Create completion
606
+ # Handle streaming vs non-streaming
597
607
  if chat_request.stream:
598
- async def streaming():
599
- try:
600
- logger.info(f"Creating streaming completion with {provider_class.__name__}")
601
- completion_stream = provider.chat.completions.create(**params)
602
- logger.info(f"Got streaming response: {type(completion_stream)}")
603
-
604
- if isinstance(completion_stream, Generator):
605
- for chunk in completion_stream:
606
- logger.debug(f"Streaming chunk: {type(chunk)}")
607
- if hasattr(chunk, 'to_dict'):
608
- # Use to_dict() for our custom dataclasses
609
- yield f"data: {json.dumps(chunk.to_dict())}\n\n"
610
- elif hasattr(chunk, 'model_dump'):
611
- # For Pydantic models
612
- yield f"data: {json.dumps(chunk.model_dump())}\n\n"
613
- else:
614
- # For dictionaries or other JSON-serializable objects
615
- yield f"data: {json.dumps(chunk)}\n\n"
616
- else:
617
- # If the provider doesn't implement streaming but stream=True,
618
- # simulate streaming with a single chunk
619
- logger.info(f"Provider returned non-streaming response, simulating stream")
620
- yield f"data: {json.dumps(completion_stream)}\n\n"
621
- except Exception as e:
622
- logger.exception(f"Error in streaming: {e}")
623
- yield f"data: {format_exception(e)}\n\n"
624
- yield "data: [DONE]\n\n"
625
-
626
- return StreamingResponse(streaming(), media_type="text/event-stream")
608
+ return await handle_streaming_response(provider, params, request_id)
627
609
  else:
628
- logger.info(f"Creating non-streaming completion with {provider_class.__name__}")
629
- try:
630
- completion = provider.chat.completions.create(**params)
631
- logger.info(f"Got completion response: {type(completion)}")
632
-
633
- # If the response is empty or None, create a default response
634
- if completion is None:
635
- logger.warning(f"Provider {provider_class.__name__} returned None for completion")
636
- return {
637
- "id": f"chatcmpl-{uuid.uuid4()}",
638
- "created": int(time.time()),
639
- "model": model,
640
- "choices": [
641
- {
642
- "index": 0,
643
- "message": {
644
- "role": "assistant",
645
- "content": "I apologize, but I couldn't generate a response. Please try again or try a different model.",
646
- },
647
- "finish_reason": "stop",
648
- }
649
- ],
650
- "usage": {
651
- "prompt_tokens": 0,
652
- "completion_tokens": 0,
653
- "total_tokens": 0,
654
- },
655
- }
656
-
657
- # Return the response in the appropriate format
658
- if isinstance(completion, dict):
659
- return completion
660
- elif hasattr(completion, "model_dump"):
661
- return completion.model_dump()
662
- else:
663
- return completion
664
- except Exception as e:
665
- logger.exception(f"Error in completion: {e}")
666
- return ErrorResponse.from_exception(e, HTTP_500_INTERNAL_SERVER_ERROR)
610
+ return await handle_non_streaming_response(provider, params, request_id, start_time)
667
611
 
612
+ except APIError:
613
+ # Re-raise API errors as-is
614
+ raise
668
615
  except Exception as e:
669
- logger.exception(e)
670
- return ErrorResponse.from_exception(e, HTTP_500_INTERNAL_SERVER_ERROR)
616
+ logger.error(f"Unexpected error in chat completion {request_id}: {e}")
617
+ raise APIError(
618
+ f"Internal server error: {str(e)}",
619
+ HTTP_500_INTERNAL_SERVER_ERROR,
620
+ "internal_error"
621
+ )
622
+
623
+
624
+ def resolve_provider_and_model(model_identifier: str) -> tuple[Any, str]:
625
+ """Resolve provider class and model name from model identifier."""
626
+ provider_class = None
627
+ model_name = None
628
+
629
+ # Check for explicit provider/model syntax
630
+ if model_identifier in AppConfig.provider_map and "/" in model_identifier:
631
+ provider_class = AppConfig.provider_map[model_identifier]
632
+ _, model_name = model_identifier.split("/", 1)
633
+ elif "/" in model_identifier:
634
+ provider_name, model_name = model_identifier.split("/", 1)
635
+ provider_class = AppConfig.provider_map.get(provider_name)
636
+ else:
637
+ provider_class = AppConfig.provider_map.get(AppConfig.default_provider)
638
+ model_name = model_identifier
639
+
640
+ if not provider_class:
641
+ available_providers = list(set(v.__name__ for v in AppConfig.provider_map.values()))
642
+ raise APIError(
643
+ f"Provider for model '{model_identifier}' not found. Available providers: {available_providers}",
644
+ HTTP_404_NOT_FOUND,
645
+ "model_not_found",
646
+ param="model"
647
+ )
648
+
649
+ # Validate model availability
650
+ if hasattr(provider_class, "AVAILABLE_MODELS") and model_name is not None:
651
+ available = getattr(provider_class, "AVAILABLE_MODELS", None)
652
+ # If it's a property, get from instance
653
+ if isinstance(available, property):
654
+ try:
655
+ available = getattr(provider_class(), "AVAILABLE_MODELS", [])
656
+ except Exception:
657
+ available = []
658
+ # If still not iterable, fallback to empty list
659
+ if not isinstance(available, (list, tuple, set)):
660
+ available = list(available) if hasattr(available, "__iter__") and not isinstance(available, str) else []
661
+ if available and model_name not in available:
662
+ raise APIError(
663
+ f"Model '{model_name}' not supported by provider '{provider_class.__name__}'. Available models: {available}",
664
+ HTTP_404_NOT_FOUND,
665
+ "model_not_found",
666
+ param="model"
667
+ )
668
+
669
+ return provider_class, model_name
670
+
671
+
672
+ def process_messages(messages: List[Message]) -> List[Dict[str, Any]]:
673
+ """Process and validate chat messages."""
674
+ processed_messages = []
675
+
676
+ for i, msg_in in enumerate(messages):
677
+ try:
678
+ message_dict_out = {"role": msg_in.role}
679
+
680
+ if msg_in.content is None:
681
+ message_dict_out["content"] = None
682
+ elif isinstance(msg_in.content, str):
683
+ message_dict_out["content"] = msg_in.content
684
+ else: # List[MessageContentParts]
685
+ message_dict_out["content"] = [
686
+ part.model_dump(exclude_none=True) for part in msg_in.content
687
+ ]
688
+
689
+ if msg_in.name:
690
+ message_dict_out["name"] = msg_in.name
691
+
692
+ processed_messages.append(message_dict_out)
693
+
694
+ except Exception as e:
695
+ raise APIError(
696
+ f"Invalid message at index {i}: {str(e)}",
697
+ HTTP_422_UNPROCESSABLE_ENTITY,
698
+ "invalid_request_error",
699
+ param=f"messages[{i}]"
700
+ )
701
+
702
+ return processed_messages
703
+
704
+
705
+ def prepare_provider_params(chat_request: ChatCompletionRequest, model_name: str,
706
+ processed_messages: List[Dict[str, Any]]) -> Dict[str, Any]:
707
+ """Prepare parameters for the provider."""
708
+ params = {
709
+ "model": model_name,
710
+ "messages": processed_messages,
711
+ "stream": chat_request.stream,
712
+ }
713
+
714
+ # Add optional parameters if present
715
+ optional_params = [
716
+ "temperature", "max_tokens", "top_p", "presence_penalty",
717
+ "frequency_penalty", "stop", "user"
718
+ ]
719
+
720
+ for param in optional_params:
721
+ value = getattr(chat_request, param, None)
722
+ if value is not None:
723
+ params[param] = value
724
+
725
+ return params
726
+
727
+
728
+ async def handle_streaming_response(provider: Any, params: Dict[str, Any], request_id: str) -> StreamingResponse:
729
+ """Handle streaming chat completion response."""
730
+ async def streaming():
731
+ try:
732
+ logger.debug(f"Starting streaming response for request {request_id}")
733
+ completion_stream = provider.chat.completions.create(**params)
734
+
735
+ # Check if it's iterable (generator, iterator, or other iterable types)
736
+ if hasattr(completion_stream, '__iter__') and not isinstance(completion_stream, (str, bytes, dict)):
737
+ try:
738
+ for chunk in completion_stream:
739
+ # Standardize chunk format before sending
740
+ if hasattr(chunk, 'model_dump'): # Pydantic v2
741
+ chunk_data = chunk.model_dump(exclude_none=True)
742
+ elif hasattr(chunk, 'dict'): # Pydantic v1
743
+ chunk_data = chunk.dict(exclude_none=True)
744
+ elif isinstance(chunk, dict):
745
+ chunk_data = chunk
746
+ else: # Fallback for unknown chunk types
747
+ chunk_data = chunk
748
+
749
+ # Clean text content in the chunk to remove control characters
750
+ if isinstance(chunk_data, dict) and 'choices' in chunk_data:
751
+ for choice in chunk_data.get('choices', []):
752
+ if isinstance(choice, dict):
753
+ # Handle delta for streaming
754
+ if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
755
+ choice['delta']['content'] = clean_text(choice['delta']['content'])
756
+ # Handle message for non-streaming
757
+ elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
758
+ choice['message']['content'] = clean_text(choice['message']['content'])
759
+
760
+ yield f"data: {json.dumps(chunk_data, ensure_ascii=False)}\n\n"
761
+ except TypeError as te:
762
+ logger.error(f"Error iterating over completion_stream: {te}")
763
+ # Fall back to treating as non-generator response
764
+ if hasattr(completion_stream, 'model_dump'):
765
+ response_data = completion_stream.model_dump(exclude_none=True)
766
+ elif hasattr(completion_stream, 'dict'):
767
+ response_data = completion_stream.dict(exclude_none=True)
768
+ else:
769
+ response_data = completion_stream
770
+
771
+ # Clean text content in the response
772
+ if isinstance(response_data, dict) and 'choices' in response_data:
773
+ for choice in response_data.get('choices', []):
774
+ if isinstance(choice, dict):
775
+ if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
776
+ choice['delta']['content'] = clean_text(choice['delta']['content'])
777
+ elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
778
+ choice['message']['content'] = clean_text(choice['message']['content'])
779
+
780
+ yield f"data: {json.dumps(response_data, ensure_ascii=False)}\n\n"
781
+ else: # Non-generator response
782
+ if hasattr(completion_stream, 'model_dump'):
783
+ response_data = completion_stream.model_dump(exclude_none=True)
784
+ elif hasattr(completion_stream, 'dict'):
785
+ response_data = completion_stream.dict(exclude_none=True)
786
+ else:
787
+ response_data = completion_stream
788
+
789
+ # Clean text content in the response
790
+ if isinstance(response_data, dict) and 'choices' in response_data:
791
+ for choice in response_data.get('choices', []):
792
+ if isinstance(choice, dict):
793
+ if 'delta' in choice and isinstance(choice['delta'], dict) and 'content' in choice['delta']:
794
+ choice['delta']['content'] = clean_text(choice['delta']['content'])
795
+ elif 'message' in choice and isinstance(choice['message'], dict) and 'content' in choice['message']:
796
+ choice['message']['content'] = clean_text(choice['message']['content'])
797
+
798
+ yield f"data: {json.dumps(response_data, ensure_ascii=False)}\n\n"
799
+
800
+ except Exception as e:
801
+ logger.error(f"Error in streaming response for request {request_id}: {e}")
802
+ error_message = clean_text(str(e))
803
+ error_data = {
804
+ "error": {
805
+ "message": error_message,
806
+ "type": "server_error",
807
+ "code": "streaming_error"
808
+ }
809
+ }
810
+ yield f"data: {json.dumps(error_data, ensure_ascii=False)}\n\n"
811
+ finally:
812
+ yield "data: [DONE]\n\n"
813
+ return StreamingResponse(streaming(), media_type="text/event-stream")
814
+
815
+
816
+ async def handle_non_streaming_response(provider: Any, params: Dict[str, Any],
817
+ request_id: str, start_time: float) -> Dict[str, Any]:
818
+ """Handle non-streaming chat completion response."""
819
+ try:
820
+ logger.debug(f"Starting non-streaming response for request {request_id}")
821
+ completion = provider.chat.completions.create(**params)
822
+
823
+ if completion is None:
824
+ # Return a valid OpenAI-compatible error response
825
+ return ChatCompletion(
826
+ id=request_id,
827
+ created=int(time.time()),
828
+ model=params.get("model", "unknown"),
829
+ choices=[Choice(
830
+ index=0,
831
+ message=ChatCompletionMessage(role="assistant", content="No response generated."),
832
+ finish_reason="error"
833
+ )],
834
+ usage=CompletionUsage(prompt_tokens=0, completion_tokens=0, total_tokens=0)
835
+ ).model_dump(exclude_none=True)
836
+
837
+ # Standardize response format
838
+ if hasattr(completion, "model_dump"): # Pydantic v2
839
+ response_data = completion.model_dump(exclude_none=True)
840
+ elif hasattr(completion, "dict"): # Pydantic v1
841
+ response_data = completion.dict(exclude_none=True)
842
+ elif isinstance(completion, dict):
843
+ response_data = completion
844
+ else:
845
+ raise APIError(
846
+ "Invalid response format from provider",
847
+ HTTP_500_INTERNAL_SERVER_ERROR,
848
+ "provider_error"
849
+ )
850
+
851
+ # Clean text content in the response to remove control characters
852
+ if isinstance(response_data, dict) and 'choices' in response_data:
853
+ for choice in response_data.get('choices', []):
854
+ if isinstance(choice, dict) and 'message' in choice:
855
+ if isinstance(choice['message'], dict) and 'content' in choice['message']:
856
+ choice['message']['content'] = clean_text(choice['message']['content'])
857
+
858
+ elapsed = time.time() - start_time
859
+ logger.info(f"Completed non-streaming request {request_id} in {elapsed:.2f}s")
860
+
861
+ return response_data
862
+
863
+ except Exception as e:
864
+ logger.error(f"Error in non-streaming response for request {request_id}: {e}")
865
+ error_message = clean_text(str(e))
866
+ raise APIError(
867
+ f"Provider error: {error_message}",
868
+ HTTP_500_INTERNAL_SERVER_ERROR,
869
+ "provider_error"
870
+ )
671
871
 
672
872
  def format_exception(e: Union[Exception, str]) -> str:
673
- """Format exception into a JSON string"""
674
873
  if isinstance(e, str):
675
874
  message = e
676
875
  else:
677
- message = f"{e.__class__.__name__}: {e}"
876
+ message = f"{e.__class__.__name__}: {str(e)}" # Keep it concise
678
877
  return json.dumps({
679
878
  "error": {
680
879
  "message": message,
681
- "type": "server_error",
880
+ "type": "server_error", # Or more specific if possible
682
881
  "param": None,
683
- "code": "internal_server_error"
882
+ "code": "internal_server_error" # Or more specific
684
883
  }
685
884
  })
686
885
 
687
- def start_server(port: int = DEFAULT_PORT, api_key: str = None, default_provider: str = None):
688
- """
689
- Simple helper function to start the OpenAI-compatible API server.
690
-
691
- Args:
692
- port: Port to run the server on (default: 8000)
693
- api_key: Optional API key for authentication
694
- default_provider: Default provider to use (e.g., "ChatGPT", "Claude", etc.)
695
-
696
- Example:
697
- ```python
698
- from webscout.Provider.OPENAI.api import start_server
699
-
700
- # Start server with default settings
701
- start_server()
702
-
703
- # Start server with custom settings
704
- start_server(port=8080, api_key="your-api-key", default_provider="Claude")
705
- ```
706
- """
886
+ def start_server(
887
+ port: int = DEFAULT_PORT,
888
+ host: str = DEFAULT_HOST,
889
+ api_key: str = None,
890
+ default_provider: str = None,
891
+ base_url: str = None,
892
+ workers: int = 1,
893
+ log_level: str = 'info',
894
+ debug: bool = False
895
+ ):
896
+ """Start the API server with the given configuration."""
707
897
  run_api(
708
- host="0.0.0.0",
898
+ host=host,
709
899
  port=port,
710
900
  api_key=api_key,
711
901
  default_provider=default_provider,
712
- debug=False,
902
+ base_url=base_url,
903
+ workers=workers,
904
+ log_level=log_level,
905
+ debug=debug,
713
906
  )
714
907
 
715
908
  def run_api(
@@ -717,94 +910,126 @@ def run_api(
717
910
  port: int = None,
718
911
  api_key: str = None,
719
912
  default_provider: str = None,
913
+ base_url: str = None,
720
914
  debug: bool = False,
915
+ workers: int = 1,
916
+ log_level: str = 'info',
721
917
  show_available_providers: bool = True,
722
918
  ) -> None:
723
- """Run the API server
724
-
725
- Args:
726
- host: Host to bind the server to
727
- port: Port to bind the server to
728
- api_key: API key for authentication (optional)
729
- default_provider: Default provider to use if no provider is specified
730
- debug: Whether to run in debug mode
731
- show_available_providers: Whether to display available providers on startup
732
- """
733
- print(f"Starting Webscout OpenAI API server...")
734
-
919
+ print("Starting Webscout OpenAI API server...")
735
920
  if port is None:
736
921
  port = DEFAULT_PORT
737
-
738
- # Set configuration
739
922
  AppConfig.set_config(
740
923
  api_key=api_key,
741
- default_provider=default_provider or AppConfig.default_provider
924
+ default_provider=default_provider or AppConfig.default_provider,
925
+ base_url=base_url
742
926
  )
743
-
744
- # Initialize provider map early to show available providers
745
- initialize_provider_map()
746
-
747
- if show_available_providers:
748
- print("\n=== Available Providers ===")
927
+ # initialize_provider_map() # This is called inside create_app now.
928
+ # Call here if create_app doesn't exist yet or for early info.
929
+ # For showing providers, it needs to be called before printing.
930
+ if show_available_providers: # Initialize map if needed for display before app creation
931
+ if not AppConfig.provider_map: # Avoid re-initializing if already done by app creation logic path
932
+ initialize_provider_map()
933
+
934
+ print("\n=== Webscout OpenAI API Server ===")
935
+ print(f"Server URL: http://{host if host != '0.0.0.0' else 'localhost'}:{port}")
936
+ if AppConfig.base_url:
937
+ print(f"Base Path: {AppConfig.base_url}")
938
+ api_endpoint_base = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}{AppConfig.base_url}"
939
+ else:
940
+ api_endpoint_base = f"http://{host if host != '0.0.0.0' else 'localhost'}:{port}"
941
+
942
+ print(f"API Endpoint: {api_endpoint_base}/v1/chat/completions")
943
+ print(f"Docs URL: {api_endpoint_base}/docs") # Adjusted for potential base_url in display
944
+ print(f"API Authentication: {'Enabled' if api_key else 'Disabled'}")
945
+ print(f"Default Provider: {AppConfig.default_provider}")
946
+ print(f"Workers: {workers}")
947
+ print(f"Log Level: {log_level}")
948
+ print(f"Debug Mode: {'Enabled' if debug else 'Disabled'}")
949
+
749
950
  providers = list(set(v.__name__ for v in AppConfig.provider_map.values()))
750
- for i, provider in enumerate(providers, 1):
751
- print(f"{i}. {provider}")
752
-
753
- print("\n=== Available Models ===")
754
- # Filter out provider class names from the model list
951
+ print(f"\n--- Available Providers ({len(providers)}) ---")
952
+ for i, provider_name in enumerate(sorted(providers), 1):
953
+ print(f"{i}. {provider_name}")
954
+
755
955
  provider_class_names = set(v.__name__ for v in AppConfig.provider_map.values())
756
- models = [model for model in AppConfig.provider_map.keys() if model not in provider_class_names]
757
-
758
- # Display models in a more organized way
956
+ models = sorted([model for model in AppConfig.provider_map.keys() if model not in provider_class_names])
759
957
  if models:
760
- for i, model in enumerate(sorted(models), 1):
761
- print(f"{i}. {model}")
958
+ print(f"\n--- Available Models ({len(models)}) ---")
959
+ for i, model_name in enumerate(models, 1):
960
+ print(f"{i}. {model_name} (via {AppConfig.provider_map[model_name].__name__})")
762
961
  else:
763
- print("No specific models registered. Use provider names as models.")
962
+ print("\nNo specific models registered. Use provider names as models.")
764
963
 
765
- print(f"\nDefault provider: {AppConfig.default_provider}")
766
- print(f"API Authentication: {'Enabled' if api_key else 'Disabled'}")
767
- print(f"Server URL: http://{host if host != '0.0.0.0' else 'localhost'}:{port}")
768
- print(f"API Endpoint: http://{host if host != '0.0.0.0' else 'localhost'}:{port}/v1/chat/completions")
769
- print(f"Documentation: http://{host if host != '0.0.0.0' else 'localhost'}:{port}/docs")
770
- print("\nUse Ctrl+C to stop the server")
771
- print("=" * 30 + "\n")
772
-
773
- # Run the server
774
- uvicorn.run(
775
- "webscout.Provider.OPENAI.api:create_app_debug" if debug else "webscout.Provider.OPENAI.api:create_app",
776
- host=host,
777
- port=int(port),
778
- factory=True,
779
- )
964
+ print("\nUse Ctrl+C to stop the server.")
965
+ print("=" * 40 + "\n")
966
+
967
+ uvicorn_app_str = "webscout.Provider.OPENAI.api:create_app_debug" if debug else "webscout.Provider.OPENAI.api:create_app"
968
+
969
+ # Configure uvicorn settings
970
+ uvicorn_config = {
971
+ "app": uvicorn_app_str,
972
+ "host": host,
973
+ "port": int(port),
974
+ "factory": True,
975
+ "reload": debug, # Enable reload only in debug mode for stability
976
+ "log_level": log_level.lower() if log_level else ("debug" if debug else "info"),
977
+ }
978
+
979
+ # Add workers only if not in debug mode (reload and workers are incompatible)
980
+ if not debug and workers > 1:
981
+ uvicorn_config["workers"] = workers
982
+ print(f"Starting with {workers} workers...")
983
+ elif debug:
984
+ print("Debug mode enabled - using single worker with reload...")
985
+
986
+ # Note: Logs show "werkzeug". If /docs 404s persist, ensure Uvicorn is the actual server running.
987
+ # The script uses uvicorn.run, so "werkzeug" logs are unexpected for this file.
988
+ uvicorn.run(**uvicorn_config)
780
989
 
781
- # Command line interface
782
990
  if __name__ == "__main__":
783
991
  import argparse
784
-
785
- parser = argparse.ArgumentParser(description="Webscout OpenAI-compatible API server")
786
- parser.add_argument("--host", default="0.0.0.0", help="Host to bind the server to")
787
- parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to bind the server to")
788
- parser.add_argument("--api-key", help="API key for authentication (optional)")
789
- parser.add_argument("--default-provider", help="Default provider to use if no provider is specified")
790
- parser.add_argument("--debug", action="store_true", help="Run in debug mode")
791
- parser.add_argument("--quiet", action="store_true", help="Don't show available providers on startup")
792
-
992
+
993
+ # Read environment variables with fallbacks
994
+ default_port = int(os.getenv('WEBSCOUT_PORT', os.getenv('PORT', DEFAULT_PORT)))
995
+ default_host = os.getenv('WEBSCOUT_HOST', DEFAULT_HOST)
996
+ default_workers = int(os.getenv('WEBSCOUT_WORKERS', '1'))
997
+ default_log_level = os.getenv('WEBSCOUT_LOG_LEVEL', 'info')
998
+ default_api_key = os.getenv('WEBSCOUT_API_KEY', os.getenv('API_KEY'))
999
+ default_provider = os.getenv('WEBSCOUT_DEFAULT_PROVIDER', os.getenv('DEFAULT_PROVIDER'))
1000
+ default_base_url = os.getenv('WEBSCOUT_BASE_URL', os.getenv('BASE_URL'))
1001
+ default_debug = os.getenv('WEBSCOUT_DEBUG', os.getenv('DEBUG', 'false')).lower() == 'true'
1002
+
1003
+ parser = argparse.ArgumentParser(description='Start Webscout OpenAI-compatible API server')
1004
+ parser.add_argument('--port', type=int, default=default_port, help=f'Port to run the server on (default: {default_port})')
1005
+ parser.add_argument('--host', type=str, default=default_host, help=f'Host to bind the server to (default: {default_host})')
1006
+ parser.add_argument('--workers', type=int, default=default_workers, help=f'Number of worker processes (default: {default_workers})')
1007
+ 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})')
1008
+ parser.add_argument('--api-key', type=str, default=default_api_key, help='API key for authentication (optional)')
1009
+ parser.add_argument('--default-provider', type=str, default=default_provider, help='Default provider to use (optional)')
1010
+ parser.add_argument('--base-url', type=str, default=default_base_url, help='Base URL for the API (optional, e.g., /api/v1)')
1011
+ parser.add_argument('--debug', action='store_true', default=default_debug, help='Run in debug mode')
793
1012
  args = parser.parse_args()
794
-
795
- try:
796
- run_api(
797
- host=args.host,
798
- port=args.port,
799
- api_key=args.api_key,
800
- default_provider=args.default_provider,
801
- debug=args.debug,
802
- show_available_providers=not args.quiet,
803
- )
804
- except KeyboardInterrupt:
805
- print("\nServer stopped by user")
806
- except Exception as e:
807
- print(f"\nError: {e}")
808
- if args.debug:
809
- import traceback
810
- traceback.print_exc()
1013
+
1014
+ # Print configuration summary
1015
+ print(f"Configuration:")
1016
+ print(f" Host: {args.host}")
1017
+ print(f" Port: {args.port}")
1018
+ print(f" Workers: {args.workers}")
1019
+ print(f" Log Level: {args.log_level}")
1020
+ print(f" Debug Mode: {args.debug}")
1021
+ print(f" API Key: {'Set' if args.api_key else 'Not set'}")
1022
+ print(f" Default Provider: {args.default_provider or 'Not set'}")
1023
+ print(f" Base URL: {args.base_url or 'Not set'}")
1024
+ print()
1025
+
1026
+ run_api(
1027
+ host=args.host,
1028
+ port=args.port,
1029
+ workers=args.workers,
1030
+ log_level=args.log_level,
1031
+ api_key=args.api_key,
1032
+ default_provider=args.default_provider,
1033
+ base_url=args.base_url,
1034
+ debug=args.debug
1035
+ )