feat: enhance HAR provider, image handling, markdown upload & cache

- **g4f/Provider/har/__init__.py**
  - `get_models`/`create_async`: iterate over `(domain, harFile)` and filter with `domain in request_url`
  - `read_har_files` now yields `(domain, har_data)`; fixes file variable shadowing and uses `json.load`
  - remove stray `print`, add type hint for `find_str`, replace manual loops with `yield from`
  - small whitespace clean-up

- **g4f/Provider/needs_auth/Grok.py**
  - `ImagePreview` now passes `auth_result.cookies` and `auth_result.headers`

- **g4f/Provider/needs_auth/OpenaiChat.py**
  - add `Union` import; rename/refactor `get_generated_images` → `get_generated_image`
  - support `file-service://` and `sediment://` pointers; choose correct download URL
  - return `ImagePreview` or `ImageResponse` accordingly and stream each image part
  - propagate 422 errors, update prompt assignment and image handling paths

- **g4f/client/__init__.py**
  - drop unused `ignore_working` parameter in sync/async `Completions`
  - normalise `media` argument: accept single tuple, infer filename when missing, fix index loop
  - `Images.create_variation` updated to use the new media logic

- **g4f/gui/server/api.py**
  - expose `latest_version_cached` via `?cache=` query parameter

- **g4f/gui/server/backend_api.py**
  - optional Markdown extraction via `MarkItDown`; save rendered text as `<file>.md`
  - upload flow rewrites: copy to temp file, move to bucket/media dir, clean temp, store filenames
  - introduce `has_markitdown` guard and improved logging/exception handling

- **g4f/tools/files.py**
  - remove trailing spaces in HAR code-block header string

- **g4f/version.py**
  - add `latest_version_cached` `@cached_property` for memoised version lookup
This commit is contained in:
hlohaus 2025-04-24 15:18:21 +02:00
parent db47d71f4c
commit 8f63f656a2
8 changed files with 95 additions and 36 deletions

View file

@ -17,10 +17,10 @@ class HarProvider(AsyncGeneratorProvider, ProviderModelMixin):
@classmethod @classmethod
def get_models(cls): def get_models(cls):
for harFile in read_har_files(): for domain, harFile in read_har_files():
for v in harFile['log']['entries']: for v in harFile['log']['entries']:
request_url = v['request']['url'] request_url = v['request']['url']
if not request_url.startswith(cls.url) or "." in urlparse(request_url).path or "heartbeat" in request_url: if domain not in request_url or "." in urlparse(request_url).path or "heartbeat" in request_url:
continue continue
if "\n\ndata: " not in v['response']['content']['text']: if "\n\ndata: " not in v['response']['content']['text']:
continue continue
@ -41,11 +41,11 @@ class HarProvider(AsyncGeneratorProvider, ProviderModelMixin):
session_hash = str(uuid.uuid4()).replace("-", "") session_hash = str(uuid.uuid4()).replace("-", "")
prompt = get_last_user_message(messages) prompt = get_last_user_message(messages)
for harFile in read_har_files(): for domain, harFile in read_har_files():
async with StreamSession(impersonate="chrome") as session: async with StreamSession(impersonate="chrome") as session:
for v in harFile['log']['entries']: for v in harFile['log']['entries']:
request_url = v['request']['url'] request_url = v['request']['url']
if not request_url.startswith(cls.url) or "." in urlparse(request_url).path or "heartbeat" in request_url: if domain not in request_url or "." in urlparse(request_url).path or "heartbeat" in request_url:
continue continue
postData = None postData = None
if "postData" in v['request']: if "postData" in v['request']:
@ -59,8 +59,6 @@ class HarProvider(AsyncGeneratorProvider, ProviderModelMixin):
async with getattr(session, method)(request_url, data=postData, headers=get_headers(v), proxy=proxy) as response: async with getattr(session, method)(request_url, data=postData, headers=get_headers(v), proxy=proxy) as response:
await raise_for_status(response) await raise_for_status(response)
if "heartbeat" in request_url:
continue
returned_data = "" returned_data = ""
async for line in response.iter_lines(): async for line in response.iter_lines():
if not line.startswith(b"data: "): if not line.startswith(b"data: "):
@ -83,9 +81,9 @@ def read_har_files():
for file in files: for file in files:
if not file.endswith(".har"): if not file.endswith(".har"):
continue continue
with open(os.path.join(root, file), 'rb') as file: with open(os.path.join(root, file), 'rb') as f:
try: try:
yield json.loads(file.read()) yield os.path.splitext(file)[0], json.load(f)
except json.JSONDecodeError: except json.JSONDecodeError:
raise RuntimeError(f"Failed to read HAR file: {file}") raise RuntimeError(f"Failed to read HAR file: {file}")
@ -98,7 +96,7 @@ def read_str_recusive(data):
elif isinstance(item, str): elif isinstance(item, str):
yield item yield item
def find_str(data, skip=0): def find_str(data, skip: int = 0):
for item in read_str_recusive(data): for item in read_str_recusive(data):
if skip > 0: if skip > 0:
skip -= 1 skip -= 1
@ -110,7 +108,6 @@ def read_list_recusive(data, key):
if isinstance(data, dict): if isinstance(data, dict):
for k, v in data.items(): for k, v in data.items():
if k == key: if k == key:
print(k, v)
yield v yield v
else: else:
yield from read_list_recusive(v, key) yield from read_list_recusive(v, key)
@ -123,8 +120,7 @@ def find_list(data, key):
if isinstance(item, str): if isinstance(item, str):
yield item yield item
elif isinstance(item, list): elif isinstance(item, list):
for sub_item in item: yield from item
yield sub_item
def get_str_list(data): def get_str_list(data):
for item in data: for item in data:

View file

@ -111,7 +111,7 @@ class Grok(AsyncAuthedProvider, ProviderModelMixin):
response_data = result.get("response", {}) response_data = result.get("response", {})
image = response_data.get("streamingImageGenerationResponse", None) image = response_data.get("streamingImageGenerationResponse", None)
if image is not None: if image is not None:
yield ImagePreview(f'{cls.assets_url}/{image["imageUrl"]}', "", {"cookies": cookies, "headers": headers}) yield ImagePreview(f'{cls.assets_url}/{image["imageUrl"]}', "", {"cookies": auth_result.cookies, "headers": auth_result.headers})
token = response_data.get("token", result.get("token")) token = response_data.get("token", result.get("token"))
is_thinking = response_data.get("isThinking", result.get("isThinking")) is_thinking = response_data.get("isThinking", result.get("isThinking"))
if token: if token:

View file

@ -8,7 +8,7 @@ import json
import base64 import base64
import time import time
import random import random
from typing import AsyncIterator, Iterator, Optional, Generator, Dict from typing import AsyncIterator, Iterator, Optional, Generator, Dict, Union
from copy import copy from copy import copy
try: try:
@ -254,22 +254,43 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
return messages return messages
@classmethod @classmethod
async def get_generated_images(cls, session: StreamSession, auth_result: AuthResult, parts: list, prompt: str, conversation_id: str) -> AsyncIterator: async def get_generated_image(cls, session: StreamSession, auth_result: AuthResult, element: Union[dict, str], prompt: str = None, conversation_id: str = None) -> AsyncIterator:
download_urls = [] download_urls = []
element = element.split("sediment://")[-1] is_sediment = False
url = f"{cls.url}/backend-api/conversation/{conversation_id}/attachment/{element}/download" if prompt is None:
debug.log(f"OpenaiChat: Downloading image: {url}") try:
prompt = element["metadata"]["dalle"]["prompt"]
except KeyError:
pass
if "asset_pointer" in element:
element = element["asset_pointer"]
if isinstance(element, str) and element.startswith("file-service://"):
element = element.split("file-service://", 1)[-1]
if isinstance(element, str) and element.startswith("sediment://"):
is_sediment = True
element = element.split("sediment://")[-1]
else:
raise RuntimeError(f"Invalid image element: {element}")
if is_sediment:
url = f"{cls.url}/backend-api/conversation/{conversation_id}/attachment/{element}/download"
else:
url =f"{cls.url}/backend-api/files/{element}/download"
try: try:
async with session.get(url, headers=auth_result.headers) as response: async with session.get(url, headers=auth_result.headers) as response:
cls._update_request_args(auth_result, session) cls._update_request_args(auth_result, session)
await raise_for_status(response) await raise_for_status(response)
data = await response.json() data = await response.json()
download_url = data.get("download_url") download_url = data.get("download_url")
download_urls.append(download_url) if download_url is not None:
download_urls.append(download_url)
debug.log(f"OpenaiChat: Found image: {download_url}")
else:
debug.log("OpenaiChat: No download URL found in response: ", data)
except Exception as e: except Exception as e:
debug.error("OpenaiChat: Download image failed") debug.error("OpenaiChat: Download image failed")
debug.error(e) debug.error(e)
return ImagePreview(download_urls, prompt) if download_urls:
return ImagePreview(download_urls, prompt) if is_sediment else ImageResponse(download_urls, prompt)
@classmethod @classmethod
async def create_authed( async def create_authed(
@ -402,7 +423,7 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
if conversation.conversation_id is not None: if conversation.conversation_id is not None:
data["conversation_id"] = conversation.conversation_id data["conversation_id"] = conversation.conversation_id
debug.log(f"OpenaiChat: Use conversation: {conversation.conversation_id}") debug.log(f"OpenaiChat: Use conversation: {conversation.conversation_id}")
conversation.prompt = format_image_prompt(messages, prompt) prompt = conversation.prompt = format_image_prompt(messages, prompt)
if action != "continue": if action != "continue":
data["parent_message_id"] = getattr(conversation, "parent_message_id", conversation.message_id) data["parent_message_id"] = getattr(conversation, "parent_message_id", conversation.message_id)
conversation.parent_message_id = None conversation.parent_message_id = None
@ -430,6 +451,8 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
cls._update_request_args(auth_result, session) cls._update_request_args(auth_result, session)
if response.status in (401, 403, 429): if response.status in (401, 403, 429):
raise MissingAuthError("Access token is not valid") raise MissingAuthError("Access token is not valid")
elif response.status == 422:
raise RuntimeError((await response.json()), data)
await raise_for_status(response) await raise_for_status(response)
buffer = u"" buffer = u""
async for line in response.iter_lines(): async for line in response.iter_lines():
@ -515,7 +538,9 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
elif m.get("p") == "/message/metadata/image_gen_title": elif m.get("p") == "/message/metadata/image_gen_title":
fields.prompt = m.get("v") fields.prompt = m.get("v")
elif m.get("p") == "/message/content/parts/0/asset_pointer": elif m.get("p") == "/message/content/parts/0/asset_pointer":
fields.generated_images = await cls.get_generated_images(session, auth_result, m.get("v"), fields.prompt, fields.conversation_id) generated_images = fields.generated_images = await cls.get_generated_image(session, auth_result, m.get("v"), fields.prompt, fields.conversation_id)
if generated_images is not None:
yield generated_images
elif m.get("p") == "/message/metadata/search_result_groups": elif m.get("p") == "/message/metadata/search_result_groups":
for entry in [p.get("entries") for p in m.get("v")]: for entry in [p.get("entries") for p in m.get("v")]:
for link in entry: for link in entry:
@ -544,7 +569,9 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
fields.is_thinking = True fields.is_thinking = True
yield Reasoning(status=m.get("metadata", {}).get("initial_text")) yield Reasoning(status=m.get("metadata", {}).get("initial_text"))
if c.get("content_type") == "multimodal_text": if c.get("content_type") == "multimodal_text":
yield await cls.get_generated_images(session, auth_result, c.get("parts"), fields.prompt, fields.conversation_id) for part in c.get("parts"):
if isinstance(part, dict) and part.get("content_type") == "image_asset_pointer":
yield await cls.get_generated_image(session, auth_result, part, fields.prompt, fields.conversation_id)
if m.get("author", {}).get("role") == "assistant": if m.get("author", {}).get("role") == "assistant":
if fields.parent_message_id is None: if fields.parent_message_id is None:
fields.parent_message_id = v.get("message", {}).get("id") fields.parent_message_id = v.get("message", {}).get("id")

View file

@ -291,16 +291,18 @@ class Completions:
max_tokens: Optional[int] = None, max_tokens: Optional[int] = None,
stop: Optional[Union[list[str], str]] = None, stop: Optional[Union[list[str], str]] = None,
api_key: Optional[str] = None, api_key: Optional[str] = None,
ignore_working: Optional[bool] = False,
ignore_stream: Optional[bool] = False, ignore_stream: Optional[bool] = False,
**kwargs **kwargs
) -> ChatCompletion: ) -> ChatCompletion:
if isinstance(messages, str): if isinstance(messages, str):
messages = [{"role": "user", "content": messages}] messages = [{"role": "user", "content": messages}]
if image is not None: if image is not None:
kwargs["media"] = [(image, image_name)] kwargs["media"] = (image, image_name)
elif "images" in kwargs: elif "images" in kwargs:
kwargs["media"] = kwargs.pop("images") kwargs["media"] = kwargs.pop("images")
for idx, media in kwargs.get("media", []):
if not isinstance(media, (list, tuple)):
kwargs["media"][idx] = (media[0], media[1] if media[1] is not None else getattr(image, "name", None))
if provider is None: if provider is None:
provider = self.provider provider = self.provider
if provider is None: if provider is None:
@ -493,7 +495,10 @@ class Images:
proxy = self.client.proxy proxy = self.client.proxy
prompt = "create a variation of this image" prompt = "create a variation of this image"
if image is not None: if image is not None:
kwargs["media"] = [(image, None)] kwargs["media"] = image
for idx, media in kwargs.get("media", []):
if not isinstance(media, (list, tuple)):
kwargs["media"][idx] = (media[0], media[1] if media[1] is not None else getattr(image, "name", None))
error = None error = None
response = None response = None
@ -591,7 +596,6 @@ class AsyncCompletions:
max_tokens: Optional[int] = None, max_tokens: Optional[int] = None,
stop: Optional[Union[list[str], str]] = None, stop: Optional[Union[list[str], str]] = None,
api_key: Optional[str] = None, api_key: Optional[str] = None,
ignore_working: Optional[bool] = False,
ignore_stream: Optional[bool] = False, ignore_stream: Optional[bool] = False,
**kwargs **kwargs
) -> Awaitable[ChatCompletion]: ) -> Awaitable[ChatCompletion]:
@ -601,6 +605,10 @@ class AsyncCompletions:
kwargs["media"] = [(image, image_name)] kwargs["media"] = [(image, image_name)]
elif "images" in kwargs: elif "images" in kwargs:
kwargs["media"] = kwargs.pop("images") kwargs["media"] = kwargs.pop("images")
for idx, media in kwargs.get("media", []):
if not isinstance(media, (list, tuple)):
kwargs["media"][idx] = (media[0], media[1] if media[1] is not None else getattr(image, "name", None))
if provider is None: if provider is None:
provider = self.provider provider = self.provider
if provider is None: if provider is None:

View file

@ -4,7 +4,7 @@ import logging
import os import os
import asyncio import asyncio
from typing import Iterator from typing import Iterator
from flask import send_from_directory from flask import send_from_directory, request
from inspect import signature from inspect import signature
from ...errors import VersionNotFoundError, MissingAuthError from ...errors import VersionNotFoundError, MissingAuthError
@ -87,7 +87,10 @@ class Api:
latest_version = None latest_version = None
try: try:
current_version = version.utils.current_version current_version = version.utils.current_version
latest_version = version.utils.latest_version if request.args.get("cache"):
latest_version = version.utils.latest_version_cached
else:
latest_version = version.utils.latest_version
except VersionNotFoundError: except VersionNotFoundError:
pass pass
return { return {

View file

@ -16,6 +16,12 @@ from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
from hashlib import sha256 from hashlib import sha256
try:
from markitdown import MarkItDown
has_markitdown = True
except ImportError:
has_markitdown = False
from ...client.service import convert_to_provider from ...client.service import convert_to_provider
from ...providers.asyncio import to_sync_generator from ...providers.asyncio import to_sync_generator
from ...providers.response import FinishReason from ...providers.response import FinishReason
@ -299,8 +305,24 @@ class Backend_Api(Api):
filenames = [] filenames = []
media = [] media = []
for file in request.files.getlist('files'): for file in request.files.getlist('files'):
try: # Copy the file to a temporary location
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
copyfile = tempfile.NamedTemporaryFile(suffix=filename, delete=False)
shutil.copyfileobj(file.stream, copyfile)
copyfile.close()
file.stream.close()
result = None
if has_markitdown:
try:
md = MarkItDown()
result = md.convert(copyfile.name).text_content
with open(os.path.join(bucket_dir, f"{filename}.md"), 'w') as f:
f.write(f"{result.text_content}\n")
filenames.append(f"{filename}.md")
except Exception as e:
logger.exception(e)
if not result:
if is_allowed_extension(filename): if is_allowed_extension(filename):
os.makedirs(media_dir, exist_ok=True) os.makedirs(media_dir, exist_ok=True)
newfile = os.path.join(media_dir, filename) newfile = os.path.join(media_dir, filename)
@ -309,11 +331,10 @@ class Backend_Api(Api):
newfile = os.path.join(bucket_dir, filename) newfile = os.path.join(bucket_dir, filename)
filenames.append(filename) filenames.append(filename)
else: else:
os.remove(copyfile.name)
continue continue
with open(newfile, 'wb') as f: shutil.copyfile(copyfile.name, newfile)
shutil.copyfileobj(file.stream, f) os.remove(copyfile.name)
finally:
file.stream.close()
with open(os.path.join(bucket_dir, "files.txt"), 'w') as f: with open(os.path.join(bucket_dir, "files.txt"), 'w') as f:
[f.write(f"{filename}\n") for filename in filenames] [f.write(f"{filename}\n") for filename in filenames]
return {"bucket_id": bucket_id, "files": filenames, "media": media} return {"bucket_id": bucket_id, "files": filenames, "media": media}

View file

@ -187,7 +187,7 @@ def stream_read_files(bucket_dir: Path, filenames: list, delete_files: bool = Fa
else: else:
os.unlink(filepath) os.unlink(filepath)
continue continue
yield f"```{filename}\n" yield f"```{filename}\n"
if has_pypdf2 and filename.endswith(".pdf"): if has_pypdf2 and filename.endswith(".pdf"):
try: try:
reader = PyPDF2.PdfReader(file_path) reader = PyPDF2.PdfReader(file_path)

View file

@ -104,6 +104,10 @@ class VersionUtils:
return get_github_version(GITHUB_REPOSITORY) return get_github_version(GITHUB_REPOSITORY)
return get_pypi_version(PACKAGE_NAME) return get_pypi_version(PACKAGE_NAME)
@cached_property
def latest_version_cached(self) -> str:
return self.latest_version
def check_version(self) -> None: def check_version(self) -> None:
""" """
Checks if the current version of 'g4f' is up to date with the latest version. Checks if the current version of 'g4f' is up to date with the latest version.