Skip to content

Commit

Permalink
Merge branch 'xtekky:main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
kqlio67 authored Nov 25, 2024
2 parents 58bd327 + f2849fc commit 326e781
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 78 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/publish-workflow.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ on:
- '**'

jobs:
openapi:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.13
uses: actions/setup-python@v4
with:
python-version: "3.13"
cache: 'pip'
- name: Install requirements
run: |
pip install fastapi uvicorn python-multipart
pip install -r requirements-min.txt
- name: Generate openapi.json
run: |
python -m etc.tool.openapi
- uses: actions/upload-artifact@v4
with:
path: openapi.json
publish:
runs-on: ubuntu-latest
steps:
Expand Down
44 changes: 7 additions & 37 deletions docker/Dockerfile-slim
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
FROM python:bookworm
FROM python:slim-bookworm

ARG G4F_VERSION
ARG G4F_USER=g4f
ARG G4F_USER_ID=1000
ARG PYDANTIC_VERSION=1.8.1

ENV G4F_VERSION $G4F_VERSION
ENV G4F_USER $G4F_USER
Expand All @@ -12,58 +11,29 @@ ENV G4F_DIR /app

RUN apt-get update && apt-get upgrade -y \
&& apt-get install -y git \
&& apt-get install --quiet --yes --no-install-recommends \
build-essential \
# Add user and user group
&& groupadd -g $G4F_USER_ID $G4F_USER \
&& useradd -rm -G sudo -u $G4F_USER_ID -g $G4F_USER_ID $G4F_USER \
&& mkdir -p /var/log/supervisor \
&& chown "${G4F_USER_ID}:${G4F_USER_ID}" /var/log/supervisor \
&& echo "${G4F_USER}:${G4F_USER}" | chpasswd \
&& python -m pip install --upgrade pip
&& python -m pip install --upgrade pip \
&& apt-get clean \
&& rm --recursive --force /var/lib/apt/lists/* /tmp/* /var/tmp/*

USER $G4F_USER_ID
WORKDIR $G4F_DIR

ENV HOME /home/$G4F_USER
ENV PATH "${HOME}/.local/bin:${HOME}/.cargo/bin:${PATH}"
ENV PATH "${HOME}/.local/bin:${PATH}"

# Create app dir and copy the project's requirements file into it
RUN mkdir -p $G4F_DIR
COPY requirements-min.txt $G4F_DIR
COPY requirements-slim.txt $G4F_DIR

# Install rust toolchain
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y

# Upgrade pip for the latest features and install the project's Python dependencies.
RUN pip install --no-cache-dir \
--no-binary :all: \
Cython==0.29.22 \
setuptools \
# Install PyDantic
&& pip install \
-vvv \
--no-cache-dir \
--no-binary :all: \
--global-option=build_ext \
--global-option=-j8 \
pydantic==${PYDANTIC_VERSION} \
&& cat requirements-slim.txt | xargs -n 1 pip install --no-cache-dir || true \
# Remove build packages
&& pip uninstall --yes \
Cython \
setuptools

USER root

# Clean up build deps
RUN rustup self uninstall -y \
&& apt-get purge --auto-remove --yes \
build-essential \
&& apt-get clean \
&& rm --recursive --force /var/lib/apt/lists/* /tmp/* /var/tmp/*

USER $G4F_USER_ID
RUN cat requirements-slim.txt | xargs -n 1 pip install --no-cache-dir || true

# Copy the entire package into the container.
ADD --chown=$G4F_USER:$G4F_USER g4f $G4F_DIR/g4f
11 changes: 11 additions & 0 deletions etc/tool/openapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import json

from g4f.api import create_app

app = create_app()

with open("openapi.json", "w") as f:
data = json.dumps(app.openapi())
f.write(data)

print(f"openapi.json - {round(len(data)/1024, 2)} kbytes")
153 changes: 114 additions & 39 deletions g4f/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_404_NOT_FOUND,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN
HTTP_403_FORBIDDEN,
HTTP_500_INTERNAL_SERVER_ERROR,
)
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, Field
from typing import Union, Optional, List, Annotated
from typing import Union, Optional, List
try:
from typing import Annotated
except ImportError:
class Annotated:
pass

import g4f
import g4f.debug
Expand All @@ -35,7 +41,7 @@
from g4f.client.helper import filter_none
from g4f.image import is_accepted_format, images_dir
from g4f.typing import Messages
from g4f.errors import ProviderNotFoundError
from g4f.errors import ProviderNotFoundError, ModelNotFoundError, MissingAuthError
from g4f.cookies import read_cookie_files, get_cookies_dir
from g4f.Provider import ProviderType, ProviderUtils, __providers__
from g4f.gui import get_gui_app
Expand All @@ -55,6 +61,15 @@ def create_app(g4f_api_key: str = None):
)

api = Api(app, g4f_api_key=g4f_api_key)

if AppConfig.gui:
@app.get("/")
async def home():
return HTMLResponse(f'g4f v-{g4f.version.utils.current_version}:<br><br>'
'Start to chat: <a href="/chat/">/chat/</a><br>'
'Open Swagger UI at: '
'<a href="/docs">/docs</a>')

api.register_routes()
api.register_authorization()
api.register_validation_exception_handler()
Expand Down Expand Up @@ -98,11 +113,10 @@ class ProviderResponseModel(BaseModel):
id: str
object: str = "provider"
created: int
owned_by: Optional[str]
url: Optional[str]
label: Optional[str]

class ProviderResponseModelDetail(ProviderResponseModel):
class ProviderResponseDetailModel(ProviderResponseModel):
models: list[str]
image_models: list[str]
vision_models: list[str]
Expand All @@ -115,7 +129,31 @@ class ModelResponseModel(BaseModel):
owned_by: Optional[str]

class ErrorResponseModel(BaseModel):
error: str
error: ErrorResponseMessageModel
model: Optional[str] = None
provider: Optional[str] = None

class ErrorResponseMessageModel(BaseModel):
message: str

class FileResponseModel(BaseModel):
filename: str

class ErrorResponse(Response):
media_type = "application/json"

@classmethod
def from_exception(cls, exception: Exception,
config: Union[ChatCompletionsConfig, ImageGenerationConfig] = None,
status_code: int = HTTP_500_INTERNAL_SERVER_ERROR):
return cls(format_exception(exception, config), status_code)

@classmethod
def from_message(cls, message: str, status_code: int = HTTP_500_INTERNAL_SERVER_ERROR):
return cls(format_exception(message), status_code)

def render(self, content) -> bytes:
return str(content).encode(errors="ignore")

class AppConfig:
ignored_providers: Optional[list[str]] = None
Expand Down Expand Up @@ -156,15 +194,9 @@ async def authorization(request: Request, call_next):
user_g4f_api_key = await self.get_g4f_api_key(request)
except HTTPException as e:
if e.status_code == 403:
return JSONResponse(
status_code=HTTP_401_UNAUTHORIZED,
content=jsonable_encoder({"detail": "G4F API key required"}),
)
return ErrorResponse.from_message("G4F API key required", HTTP_401_UNAUTHORIZED)
if not secrets.compare_digest(self.g4f_api_key, user_g4f_api_key):
return JSONResponse(
status_code=HTTP_403_FORBIDDEN,
content=jsonable_encoder({"detail": "Invalid G4F API key"}),
)
return ErrorResponse.from_message("Invalid G4F API key", HTTP_403_FORBIDDEN)
return await call_next(request)

def register_validation_exception_handler(self):
Expand Down Expand Up @@ -197,8 +229,10 @@ async def read_root_v1():
'Open Swagger UI at: '
'<a href="/docs">/docs</a>')

@self.app.get("/v1/models")
async def models() -> list[ModelResponseModel]:
@self.app.get("/v1/models", responses={
HTTP_200_OK: {"model": List[ModelResponseModel]},
})
async def models():
model_list = dict(
(model, g4f.models.ModelUtils.convert[model])
for model in g4f.Model.__all__()
Expand All @@ -210,7 +244,10 @@ async def models() -> list[ModelResponseModel]:
'owned_by': model.base_provider
} for model_id, model in model_list.items()]

@self.app.get("/v1/models/{model_name}")
@self.app.get("/v1/models/{model_name}", responses={
HTTP_200_OK: {"model": ModelResponseModel},
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
})
async def model_info(model_name: str) -> ModelResponseModel:
if model_name in g4f.models.ModelUtils.convert:
model_info = g4f.models.ModelUtils.convert[model_name]
Expand All @@ -220,9 +257,14 @@ async def model_info(model_name: str) -> ModelResponseModel:
'created': 0,
'owned_by': model_info.base_provider
})
return JSONResponse({"error": "The model does not exist."}, HTTP_404_NOT_FOUND)
return ErrorResponse.from_message("The model does not exist.", HTTP_404_NOT_FOUND)

@self.app.post("/v1/chat/completions", response_model=ChatCompletion)
@self.app.post("/v1/chat/completions", responses={
HTTP_200_OK: {"model": ChatCompletion},
HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponseModel},
})
async def chat_completions(
config: ChatCompletionsConfig,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None,
Expand Down Expand Up @@ -282,12 +324,25 @@ async def streaming():

return StreamingResponse(streaming(), media_type="text/event-stream")

except (ModelNotFoundError, ProviderNotFoundError) as e:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_404_NOT_FOUND)
except MissingAuthError as e:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_401_UNAUTHORIZED)
except Exception as e:
logger.exception(e)
return Response(content=format_exception(e, config), status_code=500, media_type="application/json")
return ErrorResponse.from_exception(e, config, HTTP_500_INTERNAL_SERVER_ERROR)

responses = {
HTTP_200_OK: {"model": ImagesResponse},
HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponseModel},
}

@self.app.post("/v1/images/generate", response_model=ImagesResponse)
@self.app.post("/v1/images/generations", response_model=ImagesResponse)
@self.app.post("/v1/images/generate", responses=responses)
@self.app.post("/v1/images/generations", responses=responses)
async def generate_image(
request: Request,
config: ImageGenerationConfig,
Expand All @@ -310,12 +365,20 @@ async def generate_image(
if hasattr(image, "url") and image.url.startswith("/"):
image.url = f"{request.base_url}{image.url.lstrip('/')}"
return response
except (ModelNotFoundError, ProviderNotFoundError) as e:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_404_NOT_FOUND)
except MissingAuthError as e:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_401_UNAUTHORIZED)
except Exception as e:
logger.exception(e)
return Response(content=format_exception(e, config, True), status_code=500, media_type="application/json")
return ErrorResponse.from_exception(e, config, HTTP_500_INTERNAL_SERVER_ERROR)

@self.app.get("/v1/providers")
async def providers() -> list[ProviderResponseModel]:
@self.app.get("/v1/providers", responses={
HTTP_200_OK: {"model": List[ProviderResponseModel]},
})
async def providers():
return [{
'id': provider.__name__,
'object': 'provider',
Expand All @@ -324,10 +387,13 @@ async def providers() -> list[ProviderResponseModel]:
'label': getattr(provider, "label", None),
} for provider in __providers__ if provider.working]

@self.app.get("/v1/providers/{provider}")
async def providers_info(provider: str) -> ProviderResponseModelDetail:
@self.app.get("/v1/providers/{provider}", responses={
HTTP_200_OK: {"model": ProviderResponseDetailModel},
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
})
async def providers_info(provider: str):
if provider not in ProviderUtils.convert:
return JSONResponse({"error": "The provider does not exist."}, 404)
return ErrorResponse.from_message("The provider does not exist.", 404)
provider: ProviderType = ProviderUtils.convert[provider]
def safe_get_models(provider: ProviderType) -> list[str]:
try:
Expand All @@ -346,7 +412,9 @@ def safe_get_models(provider: ProviderType) -> list[str]:
'params': [*provider.get_parameters()] if hasattr(provider, "get_parameters") else []
}

@self.app.post("/v1/upload_cookies")
@self.app.post("/v1/upload_cookies", responses={
HTTP_200_OK: {"model": List[FileResponseModel]},
})
def upload_cookies(files: List[UploadFile]):
response_data = []
for file in files:
Expand All @@ -368,12 +436,12 @@ def upload_cookies(files: List[UploadFile]):
async def synthesize(request: Request, provider: str):
try:
provider_handler = convert_to_provider(provider)
except ProviderNotFoundError:
return JSONResponse({"error": "Provider not found"}, HTTP_404_NOT_FOUND)
except ProviderNotFoundError as e:
return ErrorResponse.from_exception(e, status_code=HTTP_404_NOT_FOUND)
if not hasattr(provider_handler, "synthesize"):
return JSONResponse({"error": "Provider doesn't support synthesize"}, HTTP_404_NOT_FOUND)
return ErrorResponse.from_message("Provider doesn't support synthesize", HTTP_404_NOT_FOUND)
if len(request.query_params) == 0:
return JSONResponse({"error": "Missing query params"}, HTTP_422_UNPROCESSABLE_ENTITY)
return ErrorResponse.from_message("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)
Expand All @@ -393,14 +461,21 @@ async def get_image(filename):

return FileResponse(target, media_type=content_type)



def format_exception(e: Exception, config: Union[ChatCompletionsConfig, ImageGenerationConfig], image: bool = False) -> str:
def format_exception(e: Union[Exception, str], config: Union[ChatCompletionsConfig, ImageGenerationConfig] = None, image: bool = False) -> str:
last_provider = {} if not image else g4f.get_last_provider(True)
provider = (AppConfig.image_provider if image else AppConfig.provider) if config.provider is None else config.provider
model = AppConfig.model if config.model is None else config.model
provider = (AppConfig.image_provider if image else AppConfig.provider)
model = AppConfig.model
if config is not None:
if config.provider is not None:
provider = config.provider
if config.model is not None:
model = config.model
if isinstance(e, str):
message = e
else:
message = f"{e.__class__.__name__}: {e}"
return json.dumps({
"error": {"message": f"{e.__class__.__name__}: {e}"},
"error": {"message": message},
"model": last_provider.get("model") if model is None else model,
**filter_none(
provider=last_provider.get("name") if provider is None else provider
Expand Down
Loading

0 comments on commit 326e781

Please sign in to comment.