返回提交历史
Modified
README.md
+6
-3
Modified
g4f/Provider/needs_auth/OpenaiChat.py
+8
-216
Modified
g4f/Provider/openai/har_file.py
+0
-1
Modified
g4f/client/__init__.py
+5
-7
Modified
g4f/client/helper.py
+0
-30
Modified
g4f/gui/server/backend.py
+2
-17
Added
g4f/providers/asyncio.py
+65
-0
Modified
g4f/providers/base_provider.py
+5
-58
Modified
setup.py
+13
-4
XFEstudio/gpt4free
Improve slim docker image example, clean up OpenaiChat provider (#2397)
* Improve slim docker image example, clean up OpenaiChat provider * Enhance event loop management for asynchronous generators * Fix attribute " shutdown_default_executor" not found in old python versions * asyncio file added with all async helpers
e4bfd9db
代码差异
9 个文件
+104
-336
@@ -105,15 +105,18 @@ docker run \
105
105
hlohaus789/g4f:latest
106
106
```
107
107
108
Or run this command to start the gui without a browser and in the debug mode:
108
Start the GUI without a browser requirement and in debug mode.
109
There's no need to update the Docker image every time.
110
Simply remove the g4f package from the image and install the Python package:
109
111
```bash
110
docker pull hlohaus789/g4f:latest-slim
111
112
docker run \
112
113
-p 8080:8080 \
113
114
-v ${PWD}/har_and_cookies:/app/har_and_cookies \
114
115
-v ${PWD}/generated_images:/app/generated_images \
115
116
hlohaus789/g4f:latest-slim \
116
python -m g4f.cli gui -debug
117
rm -r -f /app/g4f/ \
118
&& pip install -U g4f[slim] \
119
&& python -m g4f.cli gui -d
117
120
```
118
121
119
122
3. **Access the Client:**
@@ -15,18 +15,14 @@ try:
15
15
has_nodriver = True
16
16
except ImportError:
17
17
has_nodriver = False
18
try:
19
from platformdirs import user_config_dir
20
has_platformdirs = True
21
except ImportError:
22
has_platformdirs = False
23
18
24
19
from ..base_provider import AsyncGeneratorProvider, ProviderModelMixin
25
20
from ...typing import AsyncResult, Messages, Cookies, ImageType, AsyncIterator
26
21
from ...requests.raise_for_status import raise_for_status
27
from ...requests.aiohttp import StreamSession
22
from ...requests import StreamSession
23
from ...requests import get_nodriver
28
24
from ...image import ImageResponse, ImageRequest, to_image, to_bytes, is_accepted_format
29
from ...errors import MissingAuthError, ResponseError
25
from ...errors import MissingAuthError
30
26
from ...providers.response import BaseConversation, FinishReason, SynthesizeData
31
27
from ..helper import format_cookies
32
28
from ..openai.har_file import get_request_config, NoValidHarFileError
@@ -62,7 +58,7 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
62
58
supports_system_message = True
63
59
default_model = "auto"
64
60
default_vision_model = "gpt-4o"
65
fallback_models = ["auto", "gpt-4", "gpt-4o", "gpt-4o-mini", "gpt-4o-canmore", "o1-preview", "o1-mini"]
61
fallback_models = [default_model, "gpt-4", "gpt-4o", "gpt-4o-mini", "gpt-4o-canmore", "o1-preview", "o1-mini"]
66
62
vision_models = fallback_models
67
63
image_models = fallback_models
68
64
@@ -83,51 +79,6 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
83
79
cls.models = cls.fallback_models
84
80
return cls.models
85
81
86
@classmethod
87
async def create(
88
cls,
89
prompt: str = None,
90
model: str = "",
91
messages: Messages = [],
92
action: str = "next",
93
**kwargs
94
) -> Response:
95
"""
96
Create a new conversation or continue an existing one
97
98
Args:
99
prompt: The user input to start or continue the conversation
100
model: The name of the model to use for generating responses
101
messages: The list of previous messages in the conversation
102
history_disabled: A flag indicating if the history and training should be disabled
103
action: The type of action to perform, either "next", "continue", or "variant"
104
conversation_id: The ID of the existing conversation, if any
105
parent_id: The ID of the parent message, if any
106
image: The image to include in the user input, if any
107
**kwargs: Additional keyword arguments to pass to the generator
108
109
Returns:
110
A Response object that contains the generator, action, messages, and options
111
"""
112
# Add the user input to the messages list
113
if prompt is not None:
114
messages.append({
115
"role": "user",
116
"content": prompt
117
})
118
generator = cls.create_async_generator(
119
model,
120
messages,
121
return_conversation=True,
122
**kwargs
123
)
124
return Response(
125
generator,
126
action,
127
messages,
128
kwargs
129
)
130
131
82
@classmethod
132
83
async def upload_image(
133
84
cls,
@@ -189,32 +140,6 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
189
140
image_data["download_url"] = (await response.json())["download_url"]
190
141
return ImageRequest(image_data)
191
142
192
@classmethod
193
async def get_default_model(cls, session: StreamSession, headers: dict):
194
"""
195
Get the default model name from the service
196
197
Args:
198
session: The StreamSession object to use for requests
199
headers: The headers to include in the requests
200
201
Returns:
202
The default model name as a string
203
"""
204
if not cls.default_model:
205
url = f"{cls.url}/backend-anon/models" if cls._api_key is None else f"{cls.url}/backend-api/models"
206
async with session.get(url, headers=headers) as response:
207
cls._update_request_args(session)
208
if response.status == 401:
209
raise MissingAuthError('Add a .har file for OpenaiChat' if cls._api_key is None else "Invalid api key")
210
await raise_for_status(response)
211
data = await response.json()
212
if "categories" in data:
213
cls.default_model = data["categories"][-1]["default_model"]
214
return cls.default_model
215
raise ResponseError(data)
216
return cls.default_model
217
218
143
@classmethod
219
144
def create_messages(cls, messages: Messages, image_request: ImageRequest = None):
220
145
"""
@@ -296,30 +221,6 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
296
221
except Exception as e:
297
222
raise RuntimeError(f"Error in downloading image: {e}")
298
223
299
@classmethod
300
async def delete_conversation(cls, session: StreamSession, headers: dict, conversation_id: str):
301
"""
302
Deletes a conversation by setting its visibility to False.
303
304
This method sends an HTTP PATCH request to update the visibility of a conversation.
305
It's used to effectively delete a conversation from being accessed or displayed in the future.
306
307
Args:
308
session (StreamSession): The StreamSession object used for making HTTP requests.
309
headers (dict): HTTP headers to be used for the request.
310
conversation_id (str): The unique identifier of the conversation to be deleted.
311
312
Raises:
313
HTTPError: If the HTTP request fails or returns an unsuccessful status code.
314
"""
315
async with session.patch(
316
f"{cls.url}/backend-api/conversation/{conversation_id}",
317
json={"is_visible": False},
318
headers=headers
319
) as response:
320
cls._update_request_args(session)
321
...
322
323
224
@classmethod
324
225
async def create_async_generator(
325
226
cls,
@@ -327,7 +228,6 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
327
228
messages: Messages,
328
229
proxy: str = None,
329
230
timeout: int = 180,
330
api_key: str = None,
331
231
cookies: Cookies = None,
332
232
auto_continue: bool = False,
333
233
history_disabled: bool = False,
@@ -465,7 +365,6 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
465
365
continue
466
366
await raise_for_status(response)
467
367
if return_conversation:
468
history_disabled = False
469
368
yield conversation
470
369
async for line in response.iter_lines():
471
370
async for chunk in cls.iter_messages_line(session, line, conversation):
@@ -483,19 +382,6 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
483
382
else:
484
383
break
485
384
yield FinishReason(conversation.finish_reason)
486
if history_disabled and auto_continue:
487
await cls.delete_conversation(session, cls._headers, conversation.conversation_id)
488
489
@classmethod
490
async def iter_messages_chunk(
491
cls,
492
messages: AsyncIterator,
493
session: StreamSession,
494
fields: Conversation,
495
) -> AsyncIterator:
496
async for message in messages:
497
async for chunk in cls.iter_messages_line(session, message, fields):
498
yield chunk
499
385
500
386
@classmethod
501
387
async def iter_messages_line(cls, session: StreamSession, line: bytes, fields: Conversation) -> AsyncIterator:
@@ -575,15 +461,7 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
575
461
576
462
@classmethod
577
463
async def nodriver_auth(cls, proxy: str = None):
578
if has_platformdirs:
579
user_data_dir = user_config_dir("g4f-nodriver")
580
else:
581
user_data_dir = None
582
debug.log(f"Open nodriver with user_dir: {user_data_dir}")
583
browser = await nodriver.start(
584
user_data_dir=user_data_dir,
585
browser_args=None if proxy is None else [f"--proxy-server={proxy}"],
586
)
464
browser = await get_nodriver(proxy=proxy)
587
465
page = browser.main_tab
588
466
def on_request(event: nodriver.cdp.network.RequestWillBeSent):
589
467
if event.request.url == start_url or event.request.url.startswith(conversation_url):
@@ -622,14 +500,14 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
622
500
pass
623
501
for c in await page.send(nodriver.cdp.network.get_cookies([cls.url])):
624
502
RequestConfig.cookies[c.name] = c.value
625
RequestConfig.user_agent = await page.evaluate("window.navigator.userAgent")
503
user_agent = await page.evaluate("window.navigator.userAgent")
626
504
await page.select("#prompt-textarea", 240)
627
505
while True:
628
506
if RequestConfig.proof_token:
629
507
break
630
508
await asyncio.sleep(1)
631
509
await page.close()
632
cls._create_request_args(RequestConfig.cookies, RequestConfig.headers, user_agent=RequestConfig.user_agent)
510
cls._create_request_args(RequestConfig.cookies, RequestConfig.headers, user_agent=user_agent)
633
511
cls._set_api_key(RequestConfig.access_token)
634
512
635
513
@staticmethod
@@ -672,90 +550,4 @@ class Conversation(BaseConversation):
672
550
self.conversation_id = conversation_id
673
551
self.message_id = message_id
674
552
self.finish_reason = finish_reason
675
self.is_recipient = False
676
677
class Response():
678
"""
679
Class to encapsulate a response from the chat service.
680
"""
681
def __init__(
682
self,
683
generator: AsyncResult,
684
action: str,
685
messages: Messages,
686
options: dict
687
):
688
self._generator = generator
689
self.action = action
690
self.is_end = False
691
self._message = None
692
self._messages = messages
693
self._options = options
694
self._fields = None
695
696
async def generator(self) -> AsyncIterator:
697
if self._generator is not None:
698
self._generator = None
699
chunks = []
700
async for chunk in self._generator:
701
if isinstance(chunk, Conversation):
702
self._fields = chunk
703
else:
704
yield chunk
705
chunks.append(str(chunk))
706
self._message = "".join(chunks)
707
if self._fields is None:
708
raise RuntimeError("Missing response fields")
709
self.is_end = self._fields.finish_reason == "stop"
710
711
def __aiter__(self):
712
return self.generator()
713
714
async def get_message(self) -> str:
715
await self.generator()
716
return self._message
717
718
async def get_fields(self) -> dict:
719
await self.generator()
720
return {
721
"conversation_id": self._fields.conversation_id,
722
"parent_id": self._fields.message_id
723
}
724
725
async def create_next(self, prompt: str, **kwargs) -> Response:
726
return await OpenaiChat.create(
727
**self._options,
728
prompt=prompt,
729
messages=await self.get_messages(),
730
action="next",
731
**await self.get_fields(),
732
**kwargs
733
)
734
735
async def do_continue(self, **kwargs) -> Response:
736
fields = await self.get_fields()
737
if self.is_end:
738
raise RuntimeError("Can't continue message. Message already finished.")
739
return await OpenaiChat.create(
740
**self._options,
741
messages=await self.get_messages(),
742
action="continue",
743
**fields,
744
**kwargs
745
)
746
747
async def create_variant(self, **kwargs) -> Response:
748
if self.action != "next":
749
raise RuntimeError("Can't create variant from continue or variant request.")
750
return await OpenaiChat.create(
751
**self._options,
752
messages=self._messages,
753
action="variant",
754
**await self.get_fields(),
755
**kwargs
756
)
757
758
async def get_messages(self) -> list:
759
messages = self._messages
760
messages.append({"role": "assistant", "content": await self.message()})
761
return messages
553
self.is_recipient = False
@@ -25,7 +25,6 @@ class NoValidHarFileError(Exception):
25
25
pass
26
26
27
27
class RequestConfig:
28
user_agent: str = None
29
28
cookies: dict = None
30
29
headers: dict = None
31
30
access_request_id: str = None
@@ -6,7 +6,6 @@ import random
6
6
import string
7
7
import asyncio
8
8
import base64
9
import logging
10
9
from typing import Union, AsyncIterator, Iterator, Coroutine, Optional
11
10
12
11
from ..providers.base_provider import AsyncGeneratorProvider
@@ -16,13 +15,13 @@ from ..providers.types import ProviderType
16
15
from ..providers.response import ResponseType, FinishReason, BaseConversation, SynthesizeData
17
16
from ..errors import NoImageResponseError, ModelNotFoundError
18
17
from ..providers.retry_provider import IterListProvider
19
from ..providers.base_provider import get_running_loop
18
from ..providers.asyncio import get_running_loop, to_sync_generator, async_generator_to_list
20
19
from ..Provider.needs_auth.BingCreateImages import BingCreateImages
21
20
from .stubs import ChatCompletion, ChatCompletionChunk, Image, ImagesResponse
22
21
from .image_models import ImageModels
23
22
from .types import IterResponse, ImageProvider, Client as BaseClient
24
23
from .service import get_model_and_provider, get_last_provider, convert_to_provider
25
from .helper import find_stop, filter_json, filter_none, safe_aclose, to_sync_iter, to_async_iterator
24
from .helper import find_stop, filter_json, filter_none, safe_aclose, to_async_iterator
26
25
27
26
ChatCompletionResponseType = Iterator[Union[ChatCompletion, ChatCompletionChunk, BaseConversation]]
28
27
AsyncChatCompletionResponseType = AsyncIterator[Union[ChatCompletion, ChatCompletionChunk, BaseConversation]]
@@ -50,8 +49,7 @@ def iter_response(
50
49
idx = 0
51
50
52
51
if hasattr(response, '__aiter__'):
53
# It's an async iterator, wrap it into a sync iterator
54
response = to_sync_iter(response)
52
response = to_sync_generator(response)
55
53
56
54
for chunk in response:
57
55
if isinstance(chunk, FinishReason):
@@ -231,10 +229,10 @@ class Completions:
231
229
response = asyncio.run(response)
232
230
if stream and hasattr(response, '__aiter__'):
233
231
# It's an async generator, wrap it into a sync iterator
234
response = to_sync_iter(response)
232
response = to_sync_generator(response)
235
233
elif hasattr(response, '__aiter__'):
236
234
# If response is an async generator, collect it into a list
237
response = list(to_sync_iter(response))
235
response = asyncio.run(async_generator_to_list(response))
238
236
response = iter_response(response, stream, response_format, max_tokens, stop)
239
237
response = iter_append_model_and_provider(response)
240
238
if stream:
@@ -1,10 +1,7 @@
1
1
from __future__ import annotations
2
2
3
3
import re
4
import queue
5
import threading
6
4
import logging
7
import asyncio
8
5
9
6
from typing import AsyncIterator, Iterator, AsyncGenerator, Optional
10
7
@@ -53,33 +50,6 @@ async def safe_aclose(generator: AsyncGenerator) -> None:
53
50
except Exception as e:
54
51
logging.warning(f"Error while closing generator: {e}")
55
52
56
# Helper function to convert an async generator to a synchronous iterator
57
def to_sync_iter(async_gen: AsyncIterator) -> Iterator:
58
q = queue.Queue()
59
loop = asyncio.new_event_loop()
60
done = object()
61
62
def _run():
63
asyncio.set_event_loop(loop)
64
65
async def iterate():
66
try:
67
async for item in async_gen:
68
q.put(item)
69
finally:
70
q.put(done)
71
72
loop.run_until_complete(iterate())
73
loop.close()
74
75
threading.Thread(target=_run).start()
76
77
while True:
78
item = q.get()
79
if item is done:
80
break
81
yield item
82
83
53
# Helper function to convert a synchronous iterator to an async iterator
84
54
async def to_async_iterator(iterator: Iterator) -> AsyncIterator:
85
55
for item in iterator:
@@ -1,13 +1,13 @@
1
1
import json
2
import asyncio
3
2
import flask
4
3
import os
5
4
from flask import request, Flask
6
from typing import AsyncGenerator, Generator
5
from typing import Generator
7
6
from werkzeug.utils import secure_filename
8
7
9
8
from g4f.image import is_allowed_extension, to_image
10
9
from g4f.client.service import convert_to_provider
10
from g4f.providers.asyncio import to_sync_generator
11
11
from g4f.errors import ProviderNotFoundError
12
12
from g4f.cookies import get_cookies_dir
13
13
from .api import Api
@@ -19,21 +19,6 @@ def safe_iter_generator(generator: Generator) -> Generator:
19
19
yield from generator
20
20
return iter_generator()
21
21
22
def to_sync_generator(gen: AsyncGenerator) -> Generator:
23
loop = asyncio.new_event_loop()
24
asyncio.set_event_loop(loop)
25
gen = gen.__aiter__()
26
async def get_next():
27
try:
28
obj = await gen.__anext__()
29
return False, obj
30
except StopAsyncIteration: return True, None
31
while True:
32
done, obj = loop.run_until_complete(get_next())
33
if done:
34
break
35
yield obj
36
37
22
class Backend_Api(Api):
38
23
"""
39
24
Handles various endpoints in a Flask application for backend operations.
@@ -0,0 +1,65 @@
1
from __future__ import annotations
2
3
import asyncio
4
from asyncio import AbstractEventLoop, runners
5
from typing import Union, Callable, AsyncGenerator, Generator
6
7
from ..errors import NestAsyncioError
8
9
try:
10
import nest_asyncio
11
has_nest_asyncio = True
12
except ImportError:
13
has_nest_asyncio = False
14
try:
15
import uvloop
16
has_uvloop = True
17
except ImportError:
18
has_uvloop = False
19
20
def get_running_loop(check_nested: bool) -> Union[AbstractEventLoop, None]:
21
try:
22
loop = asyncio.get_running_loop()
23
# Do not patch uvloop loop because its incompatible.
24
if has_uvloop:
25
if isinstance(loop, uvloop.Loop):
26
return loop
27
if not hasattr(loop.__class__, "_nest_patched"):
28
if has_nest_asyncio:
29
nest_asyncio.apply(loop)
30
elif check_nested:
31
raise NestAsyncioError('Install "nest_asyncio" package | pip install -U nest_asyncio')
32
return loop
33
except RuntimeError:
34
pass
35
36
# Fix for RuntimeError: async generator ignored GeneratorExit
37
async def await_callback(callback: Callable):
38
return await callback()
39
40
async def async_generator_to_list(generator: AsyncGenerator) -> list:
41
return [item async for item in generator]
42
43
def to_sync_generator(generator: AsyncGenerator) -> Generator:
44
loop = get_running_loop(check_nested=False)
45
new_loop = False
46
if loop is None:
47
loop = asyncio.new_event_loop()
48
asyncio.set_event_loop(loop)
49
new_loop = True
50
gen = generator.__aiter__()
51
try:
52
while True:
53
yield loop.run_until_complete(await_callback(gen.__anext__))
54
except StopAsyncIteration:
55
pass
56
finally:
57
if new_loop:
58
try:
59
runners._cancel_all_tasks(loop)
60
loop.run_until_complete(loop.shutdown_asyncgens())
61
if hasattr(loop, "shutdown_default_executor"):
62
loop.run_until_complete(loop.shutdown_default_executor())
63
finally:
64
asyncio.set_event_loop(None)
65
loop.close()
@@ -7,30 +7,14 @@ from asyncio import AbstractEventLoop
7
7
from concurrent.futures import ThreadPoolExecutor
8
8
from abc import abstractmethod
9
9
from inspect import signature, Parameter
10
from typing import Callable, Union
11
10
12
11
from ..typing import CreateResult, AsyncResult, Messages
13
12
from .types import BaseProvider
13
from .asyncio import get_running_loop, to_sync_generator
14
14
from .response import FinishReason, BaseConversation, SynthesizeData
15
from ..errors import NestAsyncioError, ModelNotSupportedError
15
from ..errors import ModelNotSupportedError
16
16
from .. import debug
17
17
18
if sys.version_info < (3, 10):
19
NoneType = type(None)
20
else:
21
from types import NoneType
22
23
try:
24
import nest_asyncio
25
has_nest_asyncio = True
26
except ImportError:
27
has_nest_asyncio = False
28
try:
29
import uvloop
30
has_uvloop = True
31
except ImportError:
32
has_uvloop = False
33
34
18
# Set Windows event loop policy for better compatibility with asyncio and curl_cffi
35
19
if sys.platform == 'win32':
36
20
try:
@@ -41,26 +25,6 @@ if sys.platform == 'win32':
41
25
except ImportError:
42
26
pass
43
27
44
def get_running_loop(check_nested: bool) -> Union[AbstractEventLoop, None]:
45
try:
46
loop = asyncio.get_running_loop()
47
# Do not patch uvloop loop because its incompatible.
48
if has_uvloop:
49
if isinstance(loop, uvloop.Loop):
50
return loop
51
if not hasattr(loop.__class__, "_nest_patched"):
52
if has_nest_asyncio:
53
nest_asyncio.apply(loop)
54
elif check_nested:
55
raise NestAsyncioError('Install "nest_asyncio" package | pip install -U nest_asyncio')
56
return loop
57
except RuntimeError:
58
pass
59
60
# Fix for RuntimeError: async generator ignored GeneratorExit
61
async def await_callback(callback: Callable):
62
return await callback()
63
64
28
class AbstractProvider(BaseProvider):
65
29
"""
66
30
Abstract class for providing asynchronous functionality to derived classes.
@@ -136,7 +100,6 @@ class AbstractProvider(BaseProvider):
136
100
137
101
return f"g4f.Provider.{cls.__name__} supports: ({args}\n)"
138
102
139
140
103
class AsyncProvider(AbstractProvider):
141
104
"""
142
105
Provides asynchronous functionality for creating completions.
@@ -218,25 +181,9 @@ class AsyncGeneratorProvider(AsyncProvider):
218
181
Returns:
219
182
CreateResult: The result of the streaming completion creation.
220
183
"""
221
loop = get_running_loop(check_nested=False)
222
new_loop = False
223
if loop is None:
224
loop = asyncio.new_event_loop()
225
asyncio.set_event_loop(loop)
226
new_loop = True
227
228
generator = cls.create_async_generator(model, messages, stream=stream, **kwargs)
229
gen = generator.__aiter__()
230
231
try:
232
while True:
233
yield loop.run_until_complete(await_callback(gen.__anext__))
234
except StopAsyncIteration:
235
pass
236
finally:
237
if new_loop:
238
loop.close()
239
asyncio.set_event_loop(None)
184
return to_sync_generator(
185
cls.create_async_generator(model, messages, stream=stream, **kwargs)
186
)
240
187
241
188
@classmethod
242
189
async def create_async(
@@ -23,18 +23,27 @@ EXTRA_REQUIRE = {
23
23
"browser_cookie3", # get_cookies
24
24
"duckduckgo-search>=5.0" ,# internet.search
25
25
"beautifulsoup4", # internet.search and bing.create_images
26
"brotli", # openai, bing
27
26
"platformdirs",
28
"cryptography",
29
27
"aiohttp_socks", # proxy
30
28
"pillow", # image
31
29
"cairosvg", # svg image
32
30
"werkzeug", "flask", # gui
33
31
"fastapi", # api
34
"uvicorn", "nest_asyncio", # api
35
"pycryptodome", # openai
32
"uvicorn", # api
36
33
"nodriver",
37
34
],
35
'slim': [
36
"curl_cffi>=0.6.2",
37
"certifi",
38
"duckduckgo-search>=5.0" ,# internet.search
39
"beautifulsoup4", # internet.search and bing.create_images
40
"aiohttp_socks", # proxy
41
"pillow", # image
42
"cairosvg", # svg image
43
"werkzeug", "flask", # gui
44
"fastapi", # api
45
"uvicorn", # api
46
],
38
47
"image": [
39
48
"pillow",
40
49
"cairosvg",