Refactor build scripts and API to enhance model handling and improve timeout functionality

This commit is contained in:
hlohaus
2025-09-04 22:21:42 +02:00
parent 1edd0fff17
commit 25b35ddf99
8 changed files with 131 additions and 117 deletions

View File

@@ -64,7 +64,7 @@ jobs:
name: pypi-package
path: dist/
# Windows Executables with Nuitka
# Windows Executables
build-windows-exe:
runs-on: windows-latest
needs: prepare
@@ -128,7 +128,7 @@ jobs:
name: windows-exe-${{ matrix.architecture }}
path: dist/g4f-windows-*.zip
# Linux Executables with Nuitka
# Linux Executables
build-linux-exe:
runs-on: ubuntu-latest
needs: prepare
@@ -136,9 +136,11 @@ jobs:
matrix:
include:
- architecture: x64
runner: ubuntu-latest
runner-arch: x86_64
# Note: ARM64 cross-compilation requires additional setup
# Keeping architecture in matrix for future expansion
- architecture: arm64
runner: buildjet-4vcpu-ubuntu-2204-arm
runner-arch: aarch64
steps:
- uses: actions/checkout@v4
- name: Set up Python
@@ -148,7 +150,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-slim.txt
pip install -r requirements.txt
pip install nuitka
pip install -e .
- name: Write g4f_cli.py
@@ -181,7 +183,7 @@ jobs:
name: linux-exe-${{ matrix.architecture }}
path: dist/g4f-linux-*
# macOS Executables with Nuitka
# macOS Executables
build-macos-exe:
runs-on: macos-latest
needs: prepare
@@ -234,7 +236,7 @@ jobs:
name: macos-exe-${{ matrix.architecture }}
path: dist/g4f-macos-*
# Docker Images (reuse existing workflow logic)
# Docker Images
build-docker:
runs-on: ubuntu-latest
needs: prepare

View File

@@ -14,10 +14,16 @@ try:
except ImportError:
has_curl_cffi = False
try:
import nodriver
has_nodriver = True
except ImportError:
has_nodriver = False
from ...typing import AsyncResult, Messages, MediaListType
from ...requests import StreamSession, get_args_from_nodriver, raise_for_status, merge_cookies, has_nodriver
from ...requests import StreamSession, get_args_from_nodriver, raise_for_status, merge_cookies
from ...errors import ModelNotFoundError, CloudflareError, MissingAuthError
from ...providers.response import FinishReason, Usage, JsonConversation, ImageResponse
from ...providers.response import FinishReason, Usage, JsonConversation, ImageResponse, Reasoning
from ...tools.media import merge_media
from ..base_provider import AsyncGeneratorProvider, ProviderModelMixin,AuthFileMixin
from ..helper import get_last_user_message
@@ -416,6 +422,22 @@ text_models = {model["publicName"]: model["id"] for model in models if "text" in
image_models = {model["publicName"]: model["id"] for model in models if "image" in model["capabilities"]["outputCapabilities"]}
vision_models = [model["publicName"] for model in models if "image" in model["capabilities"]["inputCapabilities"]]
if has_nodriver:
async def click_trunstile(page: nodriver.Tab, element = 'document.getElementById("cf-turnstile")'):
for _ in range(3):
size = None
for idx in range(15):
size = await page.js_dumps(f'{element}?.getBoundingClientRect()||{{}}')
debug.log(f"Found size: {size.get('x'), size.get('y')}")
if "x" not in size:
break
await page.flash_point(size.get("x") + idx * 3, size.get("y") + idx * 3)
await page.mouse_click(size.get("x") + idx * 3, size.get("y") + idx * 3)
await asyncio.sleep(2)
if "x" not in size:
break
debug.log("Finished clicking trunstile.")
class LMArena(AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin):
label = "LMArena"
url = "https://lmarena.ai"
@@ -423,6 +445,7 @@ class LMArena(AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin):
api_endpoint = "https://lmarena.ai/nextjs-api/stream/create-evaluation"
working = True
active_by_default = True
use_stream_timeout = False
default_model = list(text_models.keys())[0]
models = list(text_models) + list(image_models)
@@ -496,6 +519,9 @@ class LMArena(AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin):
pass
elif has_nodriver or cls.share_url is None:
async def callback(page):
element = await page.select('[style="display: grid;"]')
if element:
await click_trunstile(page, 'document.querySelector(\'[style="display: grid;"]\')')
await page.find("Ask anything…", 120)
button = await page.find("Accept Cookies")
if button:
@@ -507,19 +533,7 @@ class LMArena(AsyncGeneratorProvider, ProviderModelMixin, AuthFileMixin):
await page.select('#cf-turnstile', 300)
debug.log("Found Element: 'cf-turnstile'")
await asyncio.sleep(3)
for _ in range(3):
size = None
for idx in range(15):
size = await page.js_dumps('document.getElementById("cf-turnstile")?.getBoundingClientRect()||{}')
debug.log("Found size:", {size.get("x"), size.get("y")})
if "x" not in size:
break
await page.flash_point(size.get("x") + idx * 2, size.get("y") + idx * 2)
await page.mouse_click(size.get("x") + idx * 2, size.get("y") + idx * 2)
await asyncio.sleep(1)
if "x" not in size:
break
debug.log("Clicked on the turnstile.")
await click_trunstile(page)
while not await page.evaluate('document.cookie.indexOf("arena-auth-prod-v1") >= 0'):
await asyncio.sleep(1)
while not await page.evaluate('document.querySelector(\'textarea\')'):

View File

@@ -70,6 +70,7 @@ from g4f.cookies import read_cookie_files, get_cookies_dir
from g4f.providers.types import ProviderType
from g4f.providers.response import AudioResponse
from g4f.providers.any_provider import AnyProvider
from g4f.providers.any_model_map import model_map, vision_models, image_models, audio_models, video_models
from g4f import Provider
from g4f.gui import get_gui_app
from .stubs import (
@@ -356,6 +357,21 @@ class Api:
})
async def models(provider: str, credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None):
if provider not in Provider.__map__:
if provider in model_map:
return {
"object": "list",
"data": [{
"id": provider,
"object": "model",
"created": 0,
"owned_by": getattr(provider, "label", provider.__name__),
"image": provider in image_models,
"vision": provider in vision_models,
"audio": provider in audio_models,
"video": provider in video_models,
"type": "image" if provider in image_models else "chat",
}]
}
return ErrorResponse.from_message("The provider does not exist.", 404)
provider: ProviderType = Provider.__map__[provider]
if not hasattr(provider, "get_models"):
@@ -415,6 +431,11 @@ class Api:
conversation_id: str = None,
x_user: Annotated[str | None, Header()] = None
):
if provider is not None and provider not in Provider.__map__:
if provider in model_map:
config.model = provider
provider = None
return ErrorResponse.from_message("Invalid provider.", HTTP_404_NOT_FOUND)
try:
if config.provider is None:
config.provider = AppConfig.provider if provider is None else provider
@@ -500,58 +521,6 @@ class Api:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_500_INTERNAL_SERVER_ERROR)
responses = {
HTTP_200_OK: {"model": ClientResponse},
HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
HTTP_404_NOT_FOUND: {"model": ErrorResponseModel},
HTTP_422_UNPROCESSABLE_ENTITY: {"model": ErrorResponseModel},
HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponseModel},
}
@self.app.post("/v1/responses", responses=responses)
async def v1_responses(
config: ResponsesConfig,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None,
provider: str = None
):
try:
if config.provider is None:
config.provider = AppConfig.provider if provider is None else provider
if config.api_key is None and credentials is not None and credentials.credentials != "secret":
config.api_key = credentials.credentials
conversation = None
if config.conversation is not None:
conversation = JsonConversation(**config.conversation)
return await self.client.responses.create(
**filter_none(
**{
"model": AppConfig.model,
"proxy": AppConfig.proxy,
**config.dict(exclude_none=True),
"conversation": conversation
},
ignored=AppConfig.ignored_providers
),
)
except (ModelNotFoundError, ProviderNotFoundError) as e:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_404_NOT_FOUND)
except (MissingAuthError, NoValidHarFileError) as e:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_401_UNAUTHORIZED)
except Exception as e:
logger.exception(e)
return ErrorResponse.from_exception(e, config, HTTP_500_INTERNAL_SERVER_ERROR)
@self.app.post("/api/{provider}/responses", responses=responses)
async def provider_responses(
provider: str,
config: ChatCompletionsConfig,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None,
):
return await v1_responses(config, credentials, provider)
responses = {
HTTP_200_OK: {"model": ImagesResponse},
HTTP_401_UNAUTHORIZED: {"model": ErrorResponseModel},
@@ -568,6 +537,11 @@ class Api:
provider: str = None,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(Api.security)] = None
):
if provider is not None and provider not in Provider.__map__:
if provider in model_map:
config.model = provider
provider = None
return ErrorResponse.from_message("Invalid provider.", HTTP_404_NOT_FOUND)
if config.provider is None:
config.provider = provider
if config.provider is None:
@@ -646,6 +620,11 @@ class Api:
prompt: Annotated[Optional[str], Form()] = "Transcribe this audio"
):
provider = provider if path_provider is None else path_provider
if provider is not None and provider not in Provider.__map__:
if provider in model_map:
model = provider
provider = None
return ErrorResponse.from_message("Invalid provider.", HTTP_404_NOT_FOUND)
kwargs = {"modalities": ["text"]}
if provider == "MarkItDown":
kwargs = {
@@ -686,6 +665,11 @@ class Api:
api_key = None
if credentials is not None and credentials.credentials != "secret":
api_key = credentials.credentials
if provider is not None and provider not in Provider.__map__:
if provider in model_map:
config.model = provider
provider = None
return ErrorResponse.from_message("Invalid provider.", HTTP_404_NOT_FOUND)
try:
audio = filter_none(voice=config.voice, format=config.response_format, language=config.language)
response = await self.client.chat.completions.create(
@@ -744,11 +728,6 @@ class Api:
read_cookie_files()
return response_data
@self.app.post("/json/{filename}")
async def get_json(filename, request: Request):
await asyncio.sleep(30)
return ""
@self.app.get("/images/{filename}", responses={
HTTP_200_OK: {"content": {"image/*": {}}},
HTTP_404_NOT_FOUND: {}
@@ -854,7 +833,7 @@ class Api:
return await get_media(filename, request, True)
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)
last_provider = {}
provider = (AppConfig.media_provider if image else AppConfig.provider)
model = AppConfig.model
if config is not None:

View File

@@ -22,8 +22,10 @@ from ...providers.base_provider import ProviderModelMixin
from ...providers.retry_provider import BaseRetryProvider
from ...providers.helper import format_media_prompt
from ...providers.response import *
from ...providers.any_model_map import model_map
from ...providers.any_provider import AnyProvider
from ...client.service import get_model_and_provider
from ... import version, models
from ... import ChatCompletion, get_model_and_provider
from ... import debug
logger = logging.getLogger(__name__)
@@ -47,11 +49,11 @@ class Api:
@staticmethod
def get_provider_models(provider: str, api_key: str = None, api_base: str = None, ignored: list = None):
def get_model_data(provider: ProviderModelMixin, model: str):
def get_model_data(provider: ProviderModelMixin, model: str, default: bool = False) -> dict:
return {
"model": model,
"label": model.split(":")[-1] if provider.__name__ == "AnyProvider" and not model.startswith("openrouter:") else model,
"default": model == provider.default_model,
"default": default or model == provider.default_model,
"vision": model in provider.vision_models,
"audio": False if provider.audio_models is None else model in provider.audio_models,
"video": model in provider.video_models,
@@ -78,6 +80,9 @@ class Api:
get_model_data(provider, model)
for model in models
]
elif provider in model_map:
return [get_model_data(AnyProvider, provider, True)]
return []
@staticmethod
@@ -144,10 +149,10 @@ class Api:
def _prepare_conversation_kwargs(self, json_data: dict):
kwargs = {**json_data}
model = json_data.get('model')
provider = json_data.get('provider')
messages = json_data.get('messages')
action = json_data.get('action')
model = kwargs.pop('model', None)
provider = kwargs.pop('provider', None)
messages = kwargs.pop('messages', None)
action = kwargs.get('action')
if action == "continue":
kwargs["tool_calls"].append({
"function": {
@@ -155,7 +160,7 @@ class Api:
},
"type": "function"
})
conversation = json_data.get("conversation")
conversation = kwargs.pop("conversation", None)
if isinstance(conversation, dict):
kwargs["conversation"] = JsonConversation(**conversation)
return {
@@ -174,10 +179,9 @@ class Api:
if "user" not in kwargs:
debug.log = decorated_log
proxy = os.environ.get("G4F_PROXY")
provider = kwargs.pop("provider", None)
try:
model, provider_handler = get_model_and_provider(
kwargs.get("model"), provider,
kwargs.get("model"), provider or AnyProvider,
has_images="media" in kwargs,
)
if "user" in kwargs:

View File

@@ -47,6 +47,8 @@ from ...image import is_allowed_extension, process_image, MEDIA_TYPE_MAP
from ...cookies import get_cookies_dir
from ...image.copy_images import secure_filename, get_source_url, get_media_dir, copy_media
from ...client.service import get_model_and_provider
from ...providers.any_model_map import model_map
from ... import Provider
from ... import models
from .api import Api
@@ -208,11 +210,19 @@ class Backend_Api(Api):
json_data["user"] = request.headers.get("x-user", "error")
json_data["referer"] = request.headers.get("referer", "")
json_data["user-agent"] = request.headers.get("user-agent", "")
kwargs = self._prepare_conversation_kwargs(json_data)
provider = kwargs.pop("provider", None)
if provider and provider not in Provider.__map__:
if provider in model_map:
kwargs['model'] = provider
provider = None
else:
return jsonify({"error": {"message": "Provider not found"}}), 404
return self.app.response_class(
safe_iter_generator(self._create_response_stream(
kwargs,
json_data.get("provider"),
provider,
json_data.get("download_media", True),
tempfiles
)),
@@ -277,18 +287,10 @@ class Backend_Api(Api):
@app.route('/backend-api/v2/create', methods=['GET'])
def create():
try:
tool_calls = []
web_search = request.args.get("web_search")
if web_search:
is_true_web_search = web_search.lower() in ["true", "1"]
web_search = None if is_true_web_search else web_search
tool_calls.append({
"function": {
"name": "search_tool",
"arguments": {"query": web_search, "instructions": "", "max_words": 1000} if web_search != "true" else {}
},
"type": "function"
})
web_search = True if is_true_web_search else web_search
do_filter = request.args.get("filter_markdown", request.args.get("json"))
cache_id = request.args.get('cache')
model, provider_handler = get_model_and_provider(
@@ -300,7 +302,7 @@ class Backend_Api(Api):
"model": model,
"messages": [{"role": "user", "content": request.args.get("prompt")}],
"stream": not do_filter and not cache_id,
"tool_calls": tool_calls,
"web_search": web_search,
}
if request.args.get("audio_provider") or request.args.get("audio"):
parameters["audio"] = {}

View File

@@ -179,9 +179,7 @@ model_map = {
},
"gpt-oss-120b": {
"Together": "openai/gpt-oss-120b",
"DeepInfra": "openai/gpt-oss-120b",
"HuggingFace": "openai/gpt-oss-120b",
"OpenRouter": "openai/gpt-oss-120b:free",
"Groq": "openai/gpt-oss-120b",
"Azure": "gpt-oss-120b",
"OpenRouterFree": "openai/gpt-oss-120b",

View File

@@ -284,6 +284,7 @@ class AsyncGeneratorProvider(AbstractProvider):
Provides asynchronous generator functionality for streaming results.
"""
supports_stream = True
use_stream_timeout = True
@classmethod
def create_completion(
@@ -309,7 +310,7 @@ class AsyncGeneratorProvider(AbstractProvider):
"""
return to_sync_generator(
cls.create_async_generator(model, messages, **kwargs),
timeout=timeout if stream_timeout is None else stream_timeout,
timeout=stream_timeout if cls.use_stream_timeout is None else timeout,
)
@staticmethod
@@ -336,7 +337,7 @@ class AsyncGeneratorProvider(AbstractProvider):
raise NotImplementedError()
@classmethod
def async_create_function(cls, *args, **kwargs) -> AsyncResult:
async def async_create_function(cls, *args, **kwargs) -> AsyncResult:
"""
Creates a completion using the synchronous method.
@@ -346,7 +347,19 @@ class AsyncGeneratorProvider(AbstractProvider):
Returns:
CreateResult: The result of the completion creation.
"""
return cls.create_async_generator(*args, **kwargs)
response = cls.create_async_generator(*args, **kwargs)
if "stream_timeout" in kwargs or "timeout" in kwargs:
while True:
try:
yield await asyncio.wait_for(
response.__anext__(),
timeout=kwargs.get("stream_timeout") if cls.use_stream_timeout else kwargs.get("timeout")
)
except StopAsyncIteration:
break
else:
async for chunk in response:
yield chunk
class ProviderModelMixin:
default_model: str = None
@@ -501,10 +514,13 @@ class AsyncAuthedProvider(AsyncGeneratorProvider, AuthFileMixin):
try:
auth_result = cls.get_auth_result()
response = to_async_iterator(cls.create_authed(model, messages, **kwargs, auth_result=auth_result))
if "stream_timeout" in kwargs:
if "stream_timeout" in kwargs or "timeout" in kwargs:
while True:
try:
yield await asyncio.wait_for(response.__anext__(), timeout=kwargs["stream_timeout"])
yield await asyncio.wait_for(
response.__anext__(),
timeout=kwargs.get("stream_timeout") if cls.use_stream_timeout else kwargs.get("timeout")
)
except StopAsyncIteration:
break
else:

View File

@@ -43,22 +43,21 @@ case "${PLATFORM}" in
;;
"darwin"|"macos")
OUTPUT_NAME="g4f-macos-${VERSION}-${ARCH}"
NUITKA_ARGS="--macos-create-app-bundle"
NUITKA_ARGS="--macos-create-app-bundle --onefile"
;;
"linux")
OUTPUT_NAME="g4f-linux-${VERSION}-${ARCH}"
NUITKA_ARGS=""
NUITKA_ARGS="--onefile"
;;
*)
OUTPUT_NAME="g4f-${PLATFORM}-${VERSION}-${ARCH}"
NUITKA_ARGS=""
NUITKA_ARGS="--onefile"
;;
esac
# Basic Nuitka arguments
NUITKA_COMMON_ARGS="
--standalone
--onefile
--output-filename=${OUTPUT_NAME}
--output-dir=${OUTPUT_DIR}
--remove-output