* Fix arm v7 build / improve api

* Update stubs.py

* Fix unit tests
This commit is contained in:
H Lohaus
2024-11-24 17:43:45 +01:00
committed by GitHub
parent 4744d0b77d
commit 804a80bc7c
12 changed files with 248 additions and 219 deletions

View File

@@ -47,7 +47,7 @@ RUN python -m pip install --upgrade pip \
--global-option=build_ext \
--global-option=-j8 \
pydantic==${PYDANTIC_VERSION} \
&& cat requirements.txt | xargs -n 1 pip install --no-cache-dir \
&& cat requirements-slim.txt | xargs -n 1 pip install --no-cache-dir || true \
# Remove build packages
&& pip uninstall --yes \
Cython \

View File

@@ -46,7 +46,6 @@ class PollinationsAI(OpenaiAPI):
seed: str = None,
**kwargs
) -> AsyncResult:
if model:
model = cls.get_model(model)
if model in cls.image_models:
if prompt is None:

View File

@@ -313,6 +313,7 @@ class Conversation(BaseConversation):
self.conversation_id = conversation_id
self.response_id = response_id
self.choice_id = choice_id
async def iter_filter_base64(response_iter: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
search_for = b'[["wrb.fr","XqA3Ic","[\\"'
end_with = b'\\'

View File

@@ -8,21 +8,29 @@ import os
import shutil
import os.path
from fastapi import FastAPI, Response, Request, UploadFile
from fastapi import FastAPI, Response, Request, UploadFile, Depends
from fastapi.middleware.wsgi import WSGIMiddleware
from fastapi.responses import StreamingResponse, RedirectResponse, HTMLResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from fastapi.security import APIKeyHeader
from starlette.exceptions import HTTPException
from starlette.status import HTTP_422_UNPROCESSABLE_ENTITY, HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
from starlette.status import (
HTTP_200_OK,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_404_NOT_FOUND,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN
)
from fastapi.encoders import jsonable_encoder
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import FileResponse
from pydantic import BaseModel
from typing import Union, Optional, List
from pydantic import BaseModel, Field
from typing import Union, Optional, List, Annotated
import g4f
import g4f.debug
from g4f.client import AsyncClient, ChatCompletion, convert_to_provider
from g4f.client import AsyncClient, ChatCompletion, ImagesResponse, convert_to_provider
from g4f.providers.response import BaseConversation
from g4f.client.helper import filter_none
from g4f.image import is_accepted_format, images_dir
@@ -30,6 +38,7 @@ from g4f.typing import Messages
from g4f.errors import ProviderNotFoundError
from g4f.cookies import read_cookie_files, get_cookies_dir
from g4f.Provider import ProviderType, ProviderUtils, __providers__
from g4f.gui import get_gui_app
logger = logging.getLogger(__name__)
@@ -50,6 +59,10 @@ def create_app(g4f_api_key: str = None):
api.register_authorization()
api.register_validation_exception_handler()
if AppConfig.gui:
gui_app = WSGIMiddleware(get_gui_app())
app.mount("/", gui_app)
# Read cookie files if not ignored
if not AppConfig.ignore_cookie_files:
read_cookie_files()
@@ -61,17 +74,17 @@ def create_app_debug(g4f_api_key: str = None):
return create_app(g4f_api_key)
class ChatCompletionsConfig(BaseModel):
messages: Messages
model: str
provider: Optional[str] = None
messages: Messages = Field(examples=[[{"role": "system", "content": ""}, {"role": "user", "content": ""}]])
model: str = Field(default="")
provider: Optional[str] = Field(examples=[None])
stream: bool = False
temperature: Optional[float] = None
max_tokens: Optional[int] = None
stop: Union[list[str], str, None] = None
api_key: Optional[str] = None
web_search: Optional[bool] = None
proxy: Optional[str] = None
conversation_id: str = None
temperature: Optional[float] = Field(examples=[None])
max_tokens: Optional[int] = Field(examples=[None])
stop: Union[list[str], str, None] = Field(examples=[None])
api_key: Optional[str] = Field(examples=[None])
web_search: Optional[bool] = Field(examples=[None])
proxy: Optional[str] = Field(examples=[None])
conversation_id: Optional[str] = Field(examples=[None])
class ImageGenerationConfig(BaseModel):
prompt: str
@@ -101,6 +114,9 @@ class ModelResponseModel(BaseModel):
created: int
owned_by: Optional[str]
class ErrorResponseModel(BaseModel):
error: str
class AppConfig:
ignored_providers: Optional[list[str]] = None
g4f_api_key: Optional[str] = None
@@ -109,6 +125,7 @@ class AppConfig:
provider: str = None
image_provider: str = None
proxy: str = None
gui: bool = False
@classmethod
def set_config(cls, **data):
@@ -129,6 +146,8 @@ class Api:
self.get_g4f_api_key = APIKeyHeader(name="g4f-api-key")
self.conversations: dict[str, dict[str, BaseConversation]] = {}
security = HTTPBearer(auto_error=False)
def register_authorization(self):
@self.app.middleware("http")
async def authorization(request: Request, call_next):
@@ -192,7 +211,7 @@ class Api:
} for model_id, model in model_list.items()]
@self.app.get("/v1/models/{model_name}")
async def model_info(model_name: str):
async def model_info(model_name: str) -> ModelResponseModel:
if model_name in g4f.models.ModelUtils.convert:
model_info = g4f.models.ModelUtils.convert[model_name]
return JSONResponse({
@@ -201,20 +220,20 @@ class Api:
'created': 0,
'owned_by': model_info.base_provider
})
return JSONResponse({"error": "The model does not exist."}, 404)
return JSONResponse({"error": "The model does not exist."}, HTTP_404_NOT_FOUND)
@self.app.post("/v1/chat/completions")
async def chat_completions(config: ChatCompletionsConfig, request: Request = None, provider: str = None):
@self.app.post("/v1/chat/completions", response_model=ChatCompletion)
async def chat_completions(
config: ChatCompletionsConfig,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None,
provider: str = None
):
try:
config.provider = provider if config.provider is None else config.provider
if config.provider is None:
config.provider = AppConfig.provider
if config.api_key is None and request is not None:
auth_header = request.headers.get("Authorization")
if auth_header is not None:
api_key = auth_header.split(None, 1)[-1]
if api_key and api_key != "Bearer":
config.api_key = api_key
if credentials is not None:
config.api_key = credentials.credentials
conversation = return_conversation = None
if config.conversation_id is not None and config.provider is not None:
@@ -242,8 +261,7 @@ class Api:
)
if not config.stream:
response: ChatCompletion = await response
return JSONResponse(response.to_json())
return await response
async def streaming():
try:
@@ -254,7 +272,7 @@ class Api:
self.conversations[config.conversation_id] = {}
self.conversations[config.conversation_id][config.provider] = chunk
else:
yield f"data: {json.dumps(chunk.to_json())}\n\n"
yield f"data: {chunk.json()}\n\n"
except GeneratorExit:
pass
except Exception as e:
@@ -268,15 +286,15 @@ class Api:
logger.exception(e)
return Response(content=format_exception(e, config), status_code=500, media_type="application/json")
@self.app.post("/v1/images/generate")
@self.app.post("/v1/images/generations")
async def generate_image(config: ImageGenerationConfig, request: Request):
if config.api_key is None:
auth_header = request.headers.get("Authorization")
if auth_header is not None:
api_key = auth_header.split(None, 1)[-1]
if api_key and api_key != "Bearer":
config.api_key = api_key
@self.app.post("/v1/images/generate", response_model=ImagesResponse)
@self.app.post("/v1/images/generations", response_model=ImagesResponse)
async def generate_image(
request: Request,
config: ImageGenerationConfig,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None
):
if credentials is not None:
config.api_key = credentials.credentials
try:
response = await self.client.images.generate(
prompt=config.prompt,
@@ -291,7 +309,7 @@ class Api:
for image in response.data:
if hasattr(image, "url") and image.url.startswith("/"):
image.url = f"{request.base_url}{image.url.lstrip('/')}"
return JSONResponse(response.to_json())
return response
except Exception as e:
logger.exception(e)
return Response(content=format_exception(e, config, True), status_code=500, media_type="application/json")
@@ -342,22 +360,29 @@ class Api:
file.file.close()
return response_data
@self.app.get("/v1/synthesize/{provider}")
@self.app.get("/v1/synthesize/{provider}", responses={
HTTP_200_OK: {"content": {"audio/*": {}}},
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorResponseModel},
})
async def synthesize(request: Request, provider: str):
try:
provider_handler = convert_to_provider(provider)
except ProviderNotFoundError:
return Response("Provider not found", 404)
return JSONResponse({"error": "Provider not found"}, HTTP_404_NOT_FOUND)
if not hasattr(provider_handler, "synthesize"):
return Response("Provider doesn't support synthesize", 500)
return JSONResponse({"error": "Provider doesn't support synthesize"}, HTTP_404_NOT_FOUND)
if len(request.query_params) == 0:
return Response("Missing query params", 500)
return JSONResponse({"error": "Missing query params"}, HTTP_422_UNPROCESSABLE_ENTITY)
response_data = provider_handler.synthesize({**request.query_params})
content_type = getattr(provider_handler, "synthesize_content_type", "application/octet-stream")
return StreamingResponse(response_data, media_type=content_type)
@self.app.get("/images/{filename}")
async def get_image(filename) -> FileResponse:
@self.app.get("/images/{filename}", response_class=FileResponse, responses={
HTTP_200_OK: {"content": {"image/*": {}}},
HTTP_404_NOT_FOUND: {}
})
async def get_image(filename):
target = os.path.join(images_dir, filename)
if not os.path.isfile(target):

View File

@@ -12,6 +12,7 @@ def main():
api_parser = subparsers.add_parser("api")
api_parser.add_argument("--bind", default="0.0.0.0:1337", help="The bind string.")
api_parser.add_argument("--debug", action="store_true", help="Enable verbose logging.")
api_parser.add_argument("--gui", "-g", default=False, action="store_true", help="Add gui to the api.")
api_parser.add_argument("--model", default=None, help="Default model for chat completion. (incompatible with --reload and --workers)")
api_parser.add_argument("--provider", choices=[provider.__name__ for provider in Provider.__providers__ if provider.working],
default=None, help="Default provider for chat completion. (incompatible with --reload and --workers)")
@@ -48,7 +49,8 @@ def run_api_args(args):
provider=args.provider,
image_provider=args.image_provider,
proxy=args.proxy,
model=args.model
model=args.model,
gui=args.gui,
)
g4f.cookies.browsers = [g4f.cookies[browser] for browser in args.cookie_browsers]
run_api(

View File

@@ -73,7 +73,7 @@ def iter_response(
finish_reason = "stop"
if stream:
yield ChatCompletionChunk(chunk, None, completion_id, int(time.time()))
yield ChatCompletionChunk.model_construct(chunk, None, completion_id, int(time.time()))
if finish_reason is not None:
break
@@ -83,12 +83,12 @@ def iter_response(
finish_reason = "stop" if finish_reason is None else finish_reason
if stream:
yield ChatCompletionChunk(None, finish_reason, completion_id, int(time.time()))
yield ChatCompletionChunk.model_construct(None, finish_reason, completion_id, int(time.time()))
else:
if response_format is not None and "type" in response_format:
if response_format["type"] == "json_object":
content = filter_json(content)
yield ChatCompletion(content, finish_reason, completion_id, int(time.time()))
yield ChatCompletion.model_construct(content, finish_reason, completion_id, int(time.time()))
# Synchronous iter_append_model_and_provider function
def iter_append_model_and_provider(response: ChatCompletionResponseType) -> ChatCompletionResponseType:
@@ -137,7 +137,7 @@ async def async_iter_response(
finish_reason = "stop"
if stream:
yield ChatCompletionChunk(chunk, None, completion_id, int(time.time()))
yield ChatCompletionChunk.model_construct(chunk, None, completion_id, int(time.time()))
if finish_reason is not None:
break
@@ -145,12 +145,12 @@ async def async_iter_response(
finish_reason = "stop" if finish_reason is None else finish_reason
if stream:
yield ChatCompletionChunk(None, finish_reason, completion_id, int(time.time()))
yield ChatCompletionChunk.model_construct(None, finish_reason, completion_id, int(time.time()))
else:
if response_format is not None and "type" in response_format:
if response_format["type"] == "json_object":
content = filter_json(content)
yield ChatCompletion(content, finish_reason, completion_id, int(time.time()))
yield ChatCompletion.model_construct(content, finish_reason, completion_id, int(time.time()))
finally:
if hasattr(response, 'aclose'):
await safe_aclose(response)
@@ -394,13 +394,13 @@ class Images:
if response_format == "b64_json":
with open(os.path.join(images_dir, os.path.basename(image_file)), "rb") as file:
image_data = base64.b64encode(file.read()).decode()
return Image(url=image_file, b64_json=image_data, revised_prompt=response.alt)
return Image(url=image_file, revised_prompt=response.alt)
return Image.model_construct(url=image_file, b64_json=image_data, revised_prompt=response.alt)
return Image.model_construct(url=image_file, revised_prompt=response.alt)
images = await asyncio.gather(*[process_image_item(image) for image in images])
else:
images = [Image(url=image, revised_prompt=response.alt) for image in response.get_list()]
images = [Image.model_construct(url=image, revised_prompt=response.alt) for image in response.get_list()]
last_provider = get_last_provider(True)
return ImagesResponse(
return ImagesResponse.model_construct(
images,
model=last_provider.get("model") if model is None else model,
provider=last_provider.get("name") if provider is None else provider

View File

@@ -1,130 +1,150 @@
from __future__ import annotations
from typing import Union
from typing import Optional, List, Dict
from time import time
class Model():
...
from .helper import filter_none
class ChatCompletion(Model):
def __init__(
self,
try:
from pydantic import BaseModel, Field
except ImportError:
class BaseModel():
@classmethod
def model_construct(cls, **data):
new = cls()
for key, value in data.items():
setattr(new, key, value)
return new
class Field():
def __init__(self, **config):
pass
class ChatCompletionChunk(BaseModel):
id: str
object: str
created: int
model: str
provider: Optional[str]
choices: List[ChatCompletionDeltaChoice]
@classmethod
def model_construct(
cls,
content: str,
finish_reason: str,
completion_id: str = None,
created: int = None
):
self.id: str = f"chatcmpl-{completion_id}" if completion_id else None
self.object: str = "chat.completion"
self.created: int = created
self.model: str = None
self.provider: str = None
self.choices = [ChatCompletionChoice(ChatCompletionMessage(content), finish_reason)]
self.usage: dict[str, int] = {
return super().model_construct(
id=f"chatcmpl-{completion_id}" if completion_id else None,
object="chat.completion.cunk",
created=created,
model=None,
provider=None,
choices=[ChatCompletionDeltaChoice.model_construct(
ChatCompletionDelta.model_construct(content),
finish_reason
)]
)
class ChatCompletionMessage(BaseModel):
role: str
content: str
@classmethod
def model_construct(cls, content: str):
return super().model_construct(role="assistant", content=content)
class ChatCompletionChoice(BaseModel):
index: int
message: ChatCompletionMessage
finish_reason: str
@classmethod
def model_construct(cls, message: ChatCompletionMessage, finish_reason: str):
return super().model_construct(index=0, message=message, finish_reason=finish_reason)
class ChatCompletion(BaseModel):
id: str
object: str
created: int
model: str
provider: Optional[str]
choices: List[ChatCompletionChoice]
usage: Dict[str, int] = Field(examples=[{
"prompt_tokens": 0, #prompt_tokens,
"completion_tokens": 0, #completion_tokens,
"total_tokens": 0, #prompt_tokens + completion_tokens,
}])
@classmethod
def model_construct(
cls,
content: str,
finish_reason: str,
completion_id: str = None,
created: int = None
):
return super().model_construct(
id=f"chatcmpl-{completion_id}" if completion_id else None,
object="chat.completion",
created=created,
model=None,
provider=None,
choices=[ChatCompletionChoice.model_construct(
ChatCompletionMessage.model_construct(content),
finish_reason
)],
usage={
"prompt_tokens": 0, #prompt_tokens,
"completion_tokens": 0, #completion_tokens,
"total_tokens": 0, #prompt_tokens + completion_tokens,
}
)
def to_json(self):
return {
**self.__dict__,
"choices": [choice.to_json() for choice in self.choices]
}
class ChatCompletionDelta(BaseModel):
role: str
content: str
class ChatCompletionChunk(Model):
def __init__(
self,
content: str,
finish_reason: str,
completion_id: str = None,
created: int = None
):
self.id: str = f"chatcmpl-{completion_id}" if completion_id else None
self.object: str = "chat.completion.chunk"
self.created: int = created
self.model: str = None
self.provider: str = None
self.choices = [ChatCompletionDeltaChoice(ChatCompletionDelta(content), finish_reason)]
@classmethod
def model_construct(cls, content: Optional[str]):
return super().model_construct(role="assistant", content=content)
def to_json(self):
return {
**self.__dict__,
"choices": [choice.to_json() for choice in self.choices]
}
class ChatCompletionDeltaChoice(BaseModel):
index: int
delta: ChatCompletionDelta
finish_reason: Optional[str]
class ChatCompletionMessage(Model):
def __init__(self, content: Union[str, None]):
self.role = "assistant"
self.content = content
@classmethod
def model_construct(cls, delta: ChatCompletionDelta, finish_reason: Optional[str]):
return super().model_construct(index=0, delta=delta, finish_reason=finish_reason)
def to_json(self):
return self.__dict__
class Image(BaseModel):
url: Optional[str]
b64_json: Optional[str]
revised_prompt: Optional[str]
class ChatCompletionChoice(Model):
def __init__(self, message: ChatCompletionMessage, finish_reason: str):
self.index = 0
self.message = message
self.finish_reason = finish_reason
@classmethod
def model_construct(cls, url: str = None, b64_json: str = None, revised_prompt: str = None):
return super().model_construct(**filter_none(
url=url,
b64_json=b64_json,
revised_prompt=revised_prompt
))
def to_json(self):
return {
**self.__dict__,
"message": self.message.to_json()
}
class ChatCompletionDelta(Model):
content: Union[str, None] = None
def __init__(self, content: Union[str, None]):
if content is not None:
self.content = content
self.role = "assistant"
def to_json(self):
return self.__dict__
class ChatCompletionDeltaChoice(Model):
def __init__(self, delta: ChatCompletionDelta, finish_reason: Union[str, None]):
self.index = 0
self.delta = delta
self.finish_reason = finish_reason
def to_json(self):
return {
**self.__dict__,
"delta": self.delta.to_json()
}
class Image(Model):
def __init__(self, url: str = None, b64_json: str = None, revised_prompt: str = None) -> None:
if url is not None:
self.url = url
if b64_json is not None:
self.b64_json = b64_json
if revised_prompt is not None:
self.revised_prompt = revised_prompt
def to_json(self):
return self.__dict__
class ImagesResponse(Model):
class ImagesResponse(BaseModel):
data: list[Image]
model: str
provider: str
created: int
def __init__(self, data: list[Image], created: int = None, model: str = None, provider: str = None) -> None:
self.data = data
@classmethod
def model_construct(cls, data: list[Image], created: int = None, model: str = None, provider: str = None):
if created is None:
created = int(time())
self.model = model
if provider is not None:
self.provider = provider
self.created = created
def to_json(self):
return {
**self.__dict__,
"data": [image.to_json() for image in self.data]
}
return super().model_construct(
data=data,
model=model,
provider=provider,
created=created
)

View File

@@ -8,6 +8,24 @@ try:
except ImportError as e:
import_error = e
def get_gui_app():
site = Website(app)
for route in site.routes:
app.add_url_rule(
route,
view_func=site.routes[route]['function'],
methods=site.routes[route]['methods'],
)
backend_api = Backend_Api(app)
for route in backend_api.routes:
app.add_url_rule(
route,
view_func = backend_api.routes[route]['function'],
methods = backend_api.routes[route]['methods'],
)
return app
def run_gui(host: str = '0.0.0.0', port: int = 8080, debug: bool = False) -> None:
if import_error is not None:
raise MissingRequirementsError(f'Install "gui" requirements | pip install -U g4f[gui]\n{import_error}')
@@ -18,21 +36,7 @@ def run_gui(host: str = '0.0.0.0', port: int = 8080, debug: bool = False) -> Non
'debug': debug
}
site = Website(app)
for route in site.routes:
app.add_url_rule(
route,
view_func = site.routes[route]['function'],
methods = site.routes[route]['methods'],
)
backend_api = Backend_Api(app)
for route in backend_api.routes:
app.add_url_rule(
route,
view_func = backend_api.routes[route]['function'],
methods = backend_api.routes[route]['methods'],
)
get_gui_app()
print(f"Running on port {config['port']}")
app.run(**config)

View File

@@ -22,11 +22,11 @@ conversations: dict[dict[str, BaseConversation]] = {}
class Api:
@staticmethod
def get_models() -> list[str]:
def get_models():
return models._all_models
@staticmethod
def get_provider_models(provider: str, api_key: str = None) -> list[dict]:
def get_provider_models(provider: str, api_key: str = None):
if provider in __map__:
provider: ProviderType = __map__[provider]
if issubclass(provider, ProviderModelMixin):
@@ -46,39 +46,7 @@ class Api:
return []
@staticmethod
def get_image_models() -> list[dict]:
image_models = []
index = []
for provider in __providers__:
if hasattr(provider, "image_models"):
if hasattr(provider, "get_models"):
provider.get_models()
parent = provider
if hasattr(provider, "parent"):
parent = __map__[provider.parent]
if parent.__name__ not in index:
for model in provider.image_models:
image_models.append({
"provider": parent.__name__,
"url": parent.url,
"label": parent.label if hasattr(parent, "label") else None,
"image_model": model,
"vision_model": getattr(parent, "default_vision_model", None)
})
index.append(parent.__name__)
elif hasattr(provider, "default_vision_model") and provider.__name__ not in index:
image_models.append({
"provider": provider.__name__,
"url": provider.url,
"label": provider.label if hasattr(provider, "label") else None,
"image_model": None,
"vision_model": provider.default_vision_model
})
index.append(provider.__name__)
return image_models
@staticmethod
def get_providers() -> list[str]:
def get_providers() -> dict[str, str]:
return {
provider.__name__: (provider.label if hasattr(provider, "label") else provider.__name__)
+ (" (Image Generation)" if getattr(provider, "image_models", None) else "")
@@ -90,7 +58,7 @@ class Api:
}
@staticmethod
def get_version():
def get_version() -> dict:
try:
current_version = version.utils.current_version
except VersionNotFoundError:

View File

@@ -3,7 +3,7 @@ import flask
import os
import logging
import asyncio
from flask import request, Flask
from flask import Flask, request, jsonify
from typing import Generator
from werkzeug.utils import secure_filename
@@ -42,17 +42,26 @@ class Backend_Api(Api):
app (Flask): Flask application instance to attach routes to.
"""
self.app: Flask = app
def jsonify_models(**kwargs):
response = self.get_models(**kwargs)
if isinstance(response, list):
return jsonify(response)
return response
def jsonify_provider_models(**kwargs):
response = self.get_provider_models(**kwargs)
if isinstance(response, list):
return jsonify(response)
return response
self.routes = {
'/backend-api/v2/models': {
'function': self.get_models,
'function': jsonify_models,
'methods': ['GET']
},
'/backend-api/v2/models/<provider>': {
'function': self.get_provider_models,
'methods': ['GET']
},
'/backend-api/v2/image_models': {
'function': self.get_image_models,
'function': jsonify_provider_models,
'methods': ['GET']
},
'/backend-api/v2/providers': {

View File

@@ -1,11 +1,12 @@
import uuid
from flask import render_template, redirect
def redirect_home():
return redirect('/chat')
class Website:
def __init__(self, app) -> None:
self.app = app
def redirect_home():
return redirect('/chat')
self.routes = {
'/': {
'function': redirect_home,
@@ -35,7 +36,7 @@ class Website:
def _chat(self, conversation_id):
if '-' not in conversation_id:
return redirect('/chat')
return redirect_home()
return render_template('index.html', chat_id=conversation_id)
def _index(self):

View File

@@ -11,7 +11,7 @@ class CloudflareError(ResponseStatusError):
...
def is_cloudflare(text: str) -> bool:
if "Generated by cloudfront" in text:
if "Generated by cloudfront" in text or '<p id="cf-spinner-please-wait">' in text:
return True
elif "<title>Attention Required! | Cloudflare</title>" in text or 'id="cf-cloudflare-status"' in text:
return True