From 25b35ddf99310ff54ce1c9f2c99cb4eec3cdceb1 Mon Sep 17 00:00:00 2001 From: hlohaus <983577+hlohaus@users.noreply.github.com> Date: Thu, 4 Sep 2025 22:21:42 +0200 Subject: [PATCH] Refactor build scripts and API to enhance model handling and improve timeout functionality --- .github/workflows/build-packages.yml | 16 ++-- g4f/Provider/needs_auth/LMArena.py | 44 +++++++---- g4f/api/__init__.py | 105 +++++++++++---------------- g4f/gui/server/api.py | 24 +++--- g4f/gui/server/backend_api.py | 24 +++--- g4f/providers/any_model_map.py | 2 - g4f/providers/base_provider.py | 26 +++++-- scripts/build-nuitka.sh | 7 +- 8 files changed, 131 insertions(+), 117 deletions(-) diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml index c24d8d9e..317f2cdb 100644 --- a/.github/workflows/build-packages.yml +++ b/.github/workflows/build-packages.yml @@ -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 diff --git a/g4f/Provider/needs_auth/LMArena.py b/g4f/Provider/needs_auth/LMArena.py index d102719c..f74e04ae 100644 --- a/g4f/Provider/needs_auth/LMArena.py +++ b/g4f/Provider/needs_auth/LMArena.py @@ -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\')'): diff --git a/g4f/api/__init__.py b/g4f/api/__init__.py index 56c9e53b..9817d41e 100644 --- a/g4f/api/__init__.py +++ b/g4f/api/__init__.py @@ -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: @@ -883,23 +862,23 @@ def run_api( **kwargs ) -> None: print(f'Starting server... [g4f v-{g4f.version.utils.current_version}]' + (" (debug)" if debug else "")) - + if use_colors is None: use_colors = debug - + if bind is not None: host, port = bind.split(":") - + if port is None: port = DEFAULT_PORT - + if AppConfig.demo and debug: method = "create_app_with_demo_and_debug" elif AppConfig.gui and debug: method = "create_app_with_gui_and_debug" else: method = "create_app_debug" if debug else "create_app" - + uvicorn.run( f"g4f.api:{method}", host=host, diff --git a/g4f/gui/server/api.py b/g4f/gui/server/api.py index 84ff9c1b..5d8ca3b2 100644 --- a/g4f/gui/server/api.py +++ b/g4f/gui/server/api.py @@ -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: diff --git a/g4f/gui/server/backend_api.py b/g4f/gui/server/backend_api.py index 398eee8b..2bdd8eac 100644 --- a/g4f/gui/server/backend_api.py +++ b/g4f/gui/server/backend_api.py @@ -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"] = {} diff --git a/g4f/providers/any_model_map.py b/g4f/providers/any_model_map.py index 1b9e8a64..f8ff9639 100644 --- a/g4f/providers/any_model_map.py +++ b/g4f/providers/any_model_map.py @@ -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", diff --git a/g4f/providers/base_provider.py b/g4f/providers/base_provider.py index 14c9b72a..07d23078 100644 --- a/g4f/providers/base_provider.py +++ b/g4f/providers/base_provider.py @@ -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: diff --git a/scripts/build-nuitka.sh b/scripts/build-nuitka.sh index 5c97ddfc..bb68725e 100755 --- a/scripts/build-nuitka.sh +++ b/scripts/build-nuitka.sh @@ -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