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.
- webscout/AIauto.py +34 -16
- webscout/AIbase.py +96 -37
- webscout/AIutel.py +491 -87
- webscout/Bard.py +441 -323
- webscout/Extra/GitToolkit/__init__.py +10 -10
- webscout/Extra/YTToolkit/ytapi/video.py +232 -232
- webscout/Litlogger/README.md +10 -0
- webscout/Litlogger/__init__.py +7 -59
- webscout/Litlogger/formats.py +4 -0
- webscout/Litlogger/handlers.py +103 -0
- webscout/Litlogger/levels.py +13 -0
- webscout/Litlogger/logger.py +92 -0
- webscout/Provider/AISEARCH/Perplexity.py +332 -358
- webscout/Provider/AISEARCH/felo_search.py +9 -35
- webscout/Provider/AISEARCH/genspark_search.py +30 -56
- webscout/Provider/AISEARCH/hika_search.py +4 -16
- webscout/Provider/AISEARCH/iask_search.py +410 -436
- webscout/Provider/AISEARCH/monica_search.py +4 -30
- webscout/Provider/AISEARCH/scira_search.py +6 -32
- webscout/Provider/AISEARCH/webpilotai_search.py +38 -64
- webscout/Provider/Blackboxai.py +155 -35
- webscout/Provider/ChatSandbox.py +2 -1
- webscout/Provider/Deepinfra.py +339 -339
- webscout/Provider/ExaChat.py +358 -358
- webscout/Provider/Gemini.py +169 -169
- webscout/Provider/GithubChat.py +1 -2
- webscout/Provider/Glider.py +3 -3
- webscout/Provider/HeckAI.py +172 -82
- webscout/Provider/LambdaChat.py +1 -0
- webscout/Provider/MCPCore.py +7 -3
- webscout/Provider/OPENAI/BLACKBOXAI.py +421 -139
- webscout/Provider/OPENAI/Cloudflare.py +38 -21
- webscout/Provider/OPENAI/FalconH1.py +457 -0
- webscout/Provider/OPENAI/FreeGemini.py +35 -18
- webscout/Provider/OPENAI/NEMOTRON.py +34 -34
- webscout/Provider/OPENAI/PI.py +427 -0
- webscout/Provider/OPENAI/Qwen3.py +304 -0
- webscout/Provider/OPENAI/README.md +952 -1253
- webscout/Provider/OPENAI/TwoAI.py +374 -0
- webscout/Provider/OPENAI/__init__.py +7 -1
- webscout/Provider/OPENAI/ai4chat.py +73 -63
- webscout/Provider/OPENAI/api.py +869 -644
- webscout/Provider/OPENAI/base.py +2 -0
- webscout/Provider/OPENAI/c4ai.py +34 -13
- webscout/Provider/OPENAI/chatgpt.py +575 -556
- webscout/Provider/OPENAI/chatgptclone.py +512 -487
- webscout/Provider/OPENAI/chatsandbox.py +11 -6
- webscout/Provider/OPENAI/copilot.py +258 -0
- webscout/Provider/OPENAI/deepinfra.py +327 -318
- webscout/Provider/OPENAI/e2b.py +140 -104
- webscout/Provider/OPENAI/exaai.py +420 -411
- webscout/Provider/OPENAI/exachat.py +448 -443
- webscout/Provider/OPENAI/flowith.py +7 -3
- webscout/Provider/OPENAI/freeaichat.py +12 -8
- webscout/Provider/OPENAI/glider.py +15 -8
- webscout/Provider/OPENAI/groq.py +5 -2
- webscout/Provider/OPENAI/heckai.py +311 -307
- webscout/Provider/OPENAI/llmchatco.py +9 -7
- webscout/Provider/OPENAI/mcpcore.py +18 -9
- webscout/Provider/OPENAI/multichat.py +7 -5
- webscout/Provider/OPENAI/netwrck.py +16 -11
- webscout/Provider/OPENAI/oivscode.py +290 -0
- webscout/Provider/OPENAI/opkfc.py +507 -496
- webscout/Provider/OPENAI/pydantic_imports.py +172 -0
- webscout/Provider/OPENAI/scirachat.py +29 -17
- webscout/Provider/OPENAI/sonus.py +308 -303
- webscout/Provider/OPENAI/standardinput.py +442 -433
- webscout/Provider/OPENAI/textpollinations.py +18 -11
- webscout/Provider/OPENAI/toolbaz.py +419 -413
- webscout/Provider/OPENAI/typefully.py +17 -10
- webscout/Provider/OPENAI/typegpt.py +21 -11
- webscout/Provider/OPENAI/uncovrAI.py +477 -462
- webscout/Provider/OPENAI/utils.py +90 -79
- webscout/Provider/OPENAI/venice.py +435 -425
- webscout/Provider/OPENAI/wisecat.py +387 -381
- webscout/Provider/OPENAI/writecream.py +166 -163
- webscout/Provider/OPENAI/x0gpt.py +26 -37
- webscout/Provider/OPENAI/yep.py +384 -356
- webscout/Provider/PI.py +2 -1
- webscout/Provider/TTI/README.md +55 -101
- webscout/Provider/TTI/__init__.py +4 -9
- webscout/Provider/TTI/aiarta.py +365 -0
- webscout/Provider/TTI/artbit.py +0 -0
- webscout/Provider/TTI/base.py +64 -0
- webscout/Provider/TTI/fastflux.py +200 -0
- webscout/Provider/TTI/magicstudio.py +201 -0
- webscout/Provider/TTI/piclumen.py +203 -0
- webscout/Provider/TTI/pixelmuse.py +225 -0
- webscout/Provider/TTI/pollinations.py +221 -0
- webscout/Provider/TTI/utils.py +11 -0
- webscout/Provider/TTS/__init__.py +2 -1
- webscout/Provider/TTS/base.py +159 -159
- webscout/Provider/TTS/openai_fm.py +129 -0
- webscout/Provider/TextPollinationsAI.py +308 -308
- webscout/Provider/TwoAI.py +239 -44
- webscout/Provider/UNFINISHED/Youchat.py +330 -330
- webscout/Provider/UNFINISHED/puterjs.py +635 -0
- webscout/Provider/UNFINISHED/test_lmarena.py +119 -119
- webscout/Provider/Writecream.py +246 -246
- webscout/Provider/__init__.py +2 -2
- webscout/Provider/ai4chat.py +33 -8
- webscout/Provider/granite.py +41 -6
- webscout/Provider/koala.py +169 -169
- webscout/Provider/oivscode.py +309 -0
- webscout/Provider/samurai.py +3 -2
- webscout/Provider/scnet.py +1 -0
- webscout/Provider/typegpt.py +3 -3
- webscout/Provider/uncovr.py +368 -368
- webscout/client.py +70 -0
- webscout/litprinter/__init__.py +58 -58
- webscout/optimizers.py +419 -419
- webscout/scout/README.md +3 -1
- webscout/scout/core/crawler.py +134 -64
- webscout/scout/core/scout.py +148 -109
- webscout/scout/element.py +106 -88
- webscout/swiftcli/Readme.md +323 -323
- webscout/swiftcli/plugins/manager.py +9 -2
- webscout/version.py +1 -1
- webscout/zeroart/__init__.py +134 -134
- webscout/zeroart/effects.py +100 -100
- webscout/zeroart/fonts.py +1238 -1238
- {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/METADATA +160 -35
- webscout-8.3.dist-info/RECORD +290 -0
- {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/WHEEL +1 -1
- {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/entry_points.txt +1 -0
- webscout/Litlogger/Readme.md +0 -175
- webscout/Litlogger/core/__init__.py +0 -6
- webscout/Litlogger/core/level.py +0 -23
- webscout/Litlogger/core/logger.py +0 -165
- webscout/Litlogger/handlers/__init__.py +0 -12
- webscout/Litlogger/handlers/console.py +0 -33
- webscout/Litlogger/handlers/file.py +0 -143
- webscout/Litlogger/handlers/network.py +0 -173
- webscout/Litlogger/styles/__init__.py +0 -7
- webscout/Litlogger/styles/colors.py +0 -249
- webscout/Litlogger/styles/formats.py +0 -458
- webscout/Litlogger/styles/text.py +0 -87
- webscout/Litlogger/utils/__init__.py +0 -6
- webscout/Litlogger/utils/detectors.py +0 -153
- webscout/Litlogger/utils/formatters.py +0 -200
- webscout/Provider/ChatGPTGratis.py +0 -194
- webscout/Provider/TTI/AiForce/README.md +0 -159
- webscout/Provider/TTI/AiForce/__init__.py +0 -22
- webscout/Provider/TTI/AiForce/async_aiforce.py +0 -224
- webscout/Provider/TTI/AiForce/sync_aiforce.py +0 -245
- webscout/Provider/TTI/FreeAIPlayground/README.md +0 -99
- webscout/Provider/TTI/FreeAIPlayground/__init__.py +0 -9
- webscout/Provider/TTI/FreeAIPlayground/async_freeaiplayground.py +0 -181
- webscout/Provider/TTI/FreeAIPlayground/sync_freeaiplayground.py +0 -180
- webscout/Provider/TTI/ImgSys/README.md +0 -174
- webscout/Provider/TTI/ImgSys/__init__.py +0 -23
- webscout/Provider/TTI/ImgSys/async_imgsys.py +0 -202
- webscout/Provider/TTI/ImgSys/sync_imgsys.py +0 -195
- webscout/Provider/TTI/MagicStudio/README.md +0 -101
- webscout/Provider/TTI/MagicStudio/__init__.py +0 -2
- webscout/Provider/TTI/MagicStudio/async_magicstudio.py +0 -111
- webscout/Provider/TTI/MagicStudio/sync_magicstudio.py +0 -109
- webscout/Provider/TTI/Nexra/README.md +0 -155
- webscout/Provider/TTI/Nexra/__init__.py +0 -22
- webscout/Provider/TTI/Nexra/async_nexra.py +0 -286
- webscout/Provider/TTI/Nexra/sync_nexra.py +0 -258
- webscout/Provider/TTI/PollinationsAI/README.md +0 -146
- webscout/Provider/TTI/PollinationsAI/__init__.py +0 -23
- webscout/Provider/TTI/PollinationsAI/async_pollinations.py +0 -311
- webscout/Provider/TTI/PollinationsAI/sync_pollinations.py +0 -265
- webscout/Provider/TTI/aiarta/README.md +0 -134
- webscout/Provider/TTI/aiarta/__init__.py +0 -2
- webscout/Provider/TTI/aiarta/async_aiarta.py +0 -482
- webscout/Provider/TTI/aiarta/sync_aiarta.py +0 -440
- webscout/Provider/TTI/artbit/README.md +0 -100
- webscout/Provider/TTI/artbit/__init__.py +0 -22
- webscout/Provider/TTI/artbit/async_artbit.py +0 -155
- webscout/Provider/TTI/artbit/sync_artbit.py +0 -148
- webscout/Provider/TTI/fastflux/README.md +0 -129
- webscout/Provider/TTI/fastflux/__init__.py +0 -22
- webscout/Provider/TTI/fastflux/async_fastflux.py +0 -261
- webscout/Provider/TTI/fastflux/sync_fastflux.py +0 -252
- webscout/Provider/TTI/huggingface/README.md +0 -114
- webscout/Provider/TTI/huggingface/__init__.py +0 -22
- webscout/Provider/TTI/huggingface/async_huggingface.py +0 -199
- webscout/Provider/TTI/huggingface/sync_huggingface.py +0 -195
- webscout/Provider/TTI/piclumen/README.md +0 -161
- webscout/Provider/TTI/piclumen/__init__.py +0 -23
- webscout/Provider/TTI/piclumen/async_piclumen.py +0 -268
- webscout/Provider/TTI/piclumen/sync_piclumen.py +0 -233
- webscout/Provider/TTI/pixelmuse/README.md +0 -79
- webscout/Provider/TTI/pixelmuse/__init__.py +0 -4
- webscout/Provider/TTI/pixelmuse/async_pixelmuse.py +0 -249
- webscout/Provider/TTI/pixelmuse/sync_pixelmuse.py +0 -182
- webscout/Provider/TTI/talkai/README.md +0 -139
- webscout/Provider/TTI/talkai/__init__.py +0 -4
- webscout/Provider/TTI/talkai/async_talkai.py +0 -229
- webscout/Provider/TTI/talkai/sync_talkai.py +0 -207
- webscout/Provider/UNFINISHED/oivscode.py +0 -351
- webscout-8.2.8.dist-info/RECORD +0 -334
- {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/licenses/LICENSE.md +0 -0
- {webscout-8.2.8.dist-info → webscout-8.3.dist-info}/top_level.txt +0 -0
webscout/Provider/OPENAI/api.py
CHANGED
|
@@ -1,121 +1,282 @@
|
|
|
1
1
|
"""
|
|
2
|
-
OpenAI-Compatible API Server
|
|
2
|
+
Webscout OpenAI-Compatible API Server
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
23
|
-
import secrets
|
|
12
|
+
import logging
|
|
24
13
|
import os
|
|
25
|
-
import
|
|
14
|
+
import secrets
|
|
15
|
+
import sys
|
|
26
16
|
import time
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
from
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
from
|
|
47
|
-
from typing import
|
|
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
|
-
|
|
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[
|
|
192
|
+
data: List[ModelInfo]
|
|
85
193
|
|
|
86
|
-
class ErrorResponse(Response):
|
|
87
|
-
media_type = "application/json"
|
|
88
194
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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=
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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=
|
|
516
|
+
content={"detail": error_messages}
|
|
308
517
|
)
|
|
309
|
-
|
|
310
|
-
|
|
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=
|
|
522
|
+
content={"detail": exc.detail}
|
|
315
523
|
)
|
|
316
|
-
|
|
317
|
-
|
|
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=
|
|
321
|
-
content=
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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("/
|
|
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
|
-
|
|
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
|
|
365
|
-
#
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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.
|
|
564
|
-
|
|
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
|
-
#
|
|
570
|
-
|
|
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
|
-
#
|
|
589
|
-
|
|
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
|
-
#
|
|
606
|
+
# Handle streaming vs non-streaming
|
|
597
607
|
if chat_request.stream:
|
|
598
|
-
|
|
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
|
-
|
|
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.
|
|
670
|
-
|
|
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(
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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=
|
|
898
|
+
host=host,
|
|
709
899
|
port=port,
|
|
710
900
|
api_key=api_key,
|
|
711
901
|
default_provider=default_provider,
|
|
712
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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("
|
|
962
|
+
print("\nNo specific models registered. Use provider names as models.")
|
|
764
963
|
|
|
765
|
-
print(
|
|
766
|
-
print(
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
"
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
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
|
+
)
|