gemini-webapi 1.14.3__tar.gz → 1.15.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/.github/workflows/github-release.yml +1 -1
  2. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/.github/workflows/pypi-publish.yml +2 -2
  3. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/PKG-INFO +75 -2
  4. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/README.md +73 -0
  5. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/pyproject.toml +1 -1
  6. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/client.py +28 -180
  7. gemini_webapi-1.15.0/src/gemini_webapi/components/__init__.py +3 -0
  8. gemini_webapi-1.15.0/src/gemini_webapi/components/gem_mixin.py +288 -0
  9. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/constants.py +3 -0
  10. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/types/image.py +4 -4
  11. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/utils/__init__.py +1 -0
  12. gemini_webapi-1.15.0/src/gemini_webapi/utils/decorators.py +52 -0
  13. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi.egg-info/PKG-INFO +75 -2
  14. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi.egg-info/SOURCES.txt +4 -1
  15. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi.egg-info/requires.txt +1 -1
  16. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/tests/test_client_features.py +11 -10
  17. gemini_webapi-1.15.0/tests/test_gem_mixin.py +88 -0
  18. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/tests/test_save_image.py +11 -4
  19. gemini_webapi-1.14.3/tests/test_rotate_cookies.py +0 -24
  20. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/.github/dependabot.yml +0 -0
  21. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/.gitignore +0 -0
  22. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/.vscode/launch.json +0 -0
  23. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/.vscode/settings.json +0 -0
  24. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/LICENSE +0 -0
  25. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/assets/banner.png +0 -0
  26. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/assets/favicon.png +0 -0
  27. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/assets/logo.svg +0 -0
  28. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/assets/sample.pdf +0 -0
  29. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/setup.cfg +0 -0
  30. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/__init__.py +0 -0
  31. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/exceptions.py +0 -0
  32. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/types/__init__.py +0 -0
  33. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/types/candidate.py +0 -0
  34. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/types/gem.py +0 -0
  35. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/types/grpc.py +0 -0
  36. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/types/modeloutput.py +0 -0
  37. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/utils/get_access_token.py +0 -0
  38. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/utils/load_browser_cookies.py +0 -0
  39. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/utils/logger.py +0 -0
  40. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/utils/rotate_1psidts.py +0 -0
  41. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi/utils/upload_file.py +0 -0
  42. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi.egg-info/dependency_links.txt +0 -0
  43. {gemini_webapi-1.14.3 → gemini_webapi-1.15.0}/src/gemini_webapi.egg-info/top_level.txt +0 -0
@@ -11,7 +11,7 @@ jobs:
11
11
  permissions:
12
12
  contents: write
13
13
  steps:
14
- - uses: actions/checkout@v4
14
+ - uses: actions/checkout@v5
15
15
  - uses: ncipollo/release-action@v1
16
16
  with:
17
17
  body: ${{ github.event.head_commit.message }}
@@ -24,7 +24,7 @@ jobs:
24
24
  name: Build package
25
25
  runs-on: ubuntu-latest
26
26
  steps:
27
- - uses: actions/checkout@v4
27
+ - uses: actions/checkout@v5
28
28
  - name: Set up Python
29
29
  uses: actions/setup-python@v5
30
30
  with:
@@ -52,7 +52,7 @@ jobs:
52
52
  id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
53
53
  steps:
54
54
  - name: Retrieve built artifacts
55
- uses: actions/download-artifact@v4.3.0
55
+ uses: actions/download-artifact@v5.0.0
56
56
  with:
57
57
  name: dist
58
58
  path: dist
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemini-webapi
3
- Version: 1.14.3
3
+ Version: 1.15.0
4
4
  Summary: ✨ An elegant async Python wrapper for Google Gemini web app
5
5
  Author: UZQueen
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -678,7 +678,7 @@ Description-Content-Type: text/markdown
678
678
  License-File: LICENSE
679
679
  Requires-Dist: httpx[http2]~=0.28.1
680
680
  Requires-Dist: loguru~=0.7.3
681
- Requires-Dist: orjson~=3.10.18
681
+ Requires-Dist: orjson~=3.11.1
682
682
  Requires-Dist: pydantic~=2.11.5
683
683
  Dynamic: license-file
684
684
 
@@ -734,6 +734,10 @@ A reverse-engineered asynchronous python wrapper for [Google Gemini](https://gem
734
734
  - [Continue previous conversations](#continue-previous-conversations)
735
735
  - [Select language model](#select-language-model)
736
736
  - [Apply system prompt with Gemini Gems](#apply-system-prompt-with-gemini-gems)
737
+ - [Manage Custom Gems](#manage-custom-gems)
738
+ - [Create a custom gem](#create-a-custom-gem)
739
+ - [Update an existing gem](#update-an-existing-gem)
740
+ - [Delete a custom gem](#delete-a-custom-gem)
737
741
  - [Retrieve model's thought process](#retrieve-models-thought-process)
738
742
  - [Retrieve images in response](#retrieve-images-in-response)
739
743
  - [Generate images with Imagen4](#generate-images-with-imagen4)
@@ -963,6 +967,75 @@ async def main():
963
967
  print(response2)
964
968
  ```
965
969
 
970
+ ### Manage Custom Gems
971
+
972
+ You can create, update, and delete your custom gems programmatically with the API. Note that predefined system gems cannot be modified or deleted.
973
+
974
+ #### Create a custom gem
975
+
976
+ Create a new custom gem with a name, system prompt (instructions), and optional description:
977
+
978
+ ```python
979
+ async def main():
980
+ # Create a new custom gem
981
+ new_gem = await client.create_gem(
982
+ name="Python Tutor",
983
+ prompt="You are a helpful Python programming tutor.",
984
+ description="A specialized gem for Python programming"
985
+ )
986
+
987
+ print(f"Custom gem created: {new_gem}")
988
+
989
+ # Use the newly created gem in a conversation
990
+ response = await client.generate_content(
991
+ "Explain how list comprehensions work in Python",
992
+ gem=new_gem
993
+ )
994
+ print(response.text)
995
+
996
+ asyncio.run(main())
997
+ ```
998
+
999
+ #### Update an existing gem
1000
+
1001
+ > [!NOTE]
1002
+ >
1003
+ > When updating a gem, you must provide all parameters (name, prompt, description) even if you only want to change one of them.
1004
+
1005
+ ```python
1006
+ async def main():
1007
+ # Get a custom gem (assuming you have one named "Python Tutor")
1008
+ await client.fetch_gems()
1009
+ python_tutor = client.gems.get(name="Python Tutor")
1010
+
1011
+ # Update the gem with new instructions
1012
+ updated_gem = await client.update_gem(
1013
+ gem=python_tutor, # Can also pass gem ID string
1014
+ name="Advanced Python Tutor",
1015
+ prompt="You are an expert Python programming tutor.",
1016
+ description="An advanced Python programming assistant"
1017
+ )
1018
+
1019
+ print(f"Custom gem updated: {updated_gem}")
1020
+
1021
+ asyncio.run(main())
1022
+ ```
1023
+
1024
+ #### Delete a custom gem
1025
+
1026
+ ```python
1027
+ async def main():
1028
+ # Get the gem to delete
1029
+ await client.fetch_gems()
1030
+ gem_to_delete = client.gems.get(name="Advanced Python Tutor")
1031
+
1032
+ # Delete the gem
1033
+ await client.delete_gem(gem_to_delete) # Can also pass gem ID string
1034
+ print(f"Custom gem deleted: {gem_to_delete.name}")
1035
+
1036
+ asyncio.run(main())
1037
+ ```
1038
+
966
1039
  ### Retrieve model's thought process
967
1040
 
968
1041
  When using models with thinking capabilities, the model's thought process will be populated in `ModelOutput.thoughts`.
@@ -50,6 +50,10 @@ A reverse-engineered asynchronous python wrapper for [Google Gemini](https://gem
50
50
  - [Continue previous conversations](#continue-previous-conversations)
51
51
  - [Select language model](#select-language-model)
52
52
  - [Apply system prompt with Gemini Gems](#apply-system-prompt-with-gemini-gems)
53
+ - [Manage Custom Gems](#manage-custom-gems)
54
+ - [Create a custom gem](#create-a-custom-gem)
55
+ - [Update an existing gem](#update-an-existing-gem)
56
+ - [Delete a custom gem](#delete-a-custom-gem)
53
57
  - [Retrieve model's thought process](#retrieve-models-thought-process)
54
58
  - [Retrieve images in response](#retrieve-images-in-response)
55
59
  - [Generate images with Imagen4](#generate-images-with-imagen4)
@@ -279,6 +283,75 @@ async def main():
279
283
  print(response2)
280
284
  ```
281
285
 
286
+ ### Manage Custom Gems
287
+
288
+ You can create, update, and delete your custom gems programmatically with the API. Note that predefined system gems cannot be modified or deleted.
289
+
290
+ #### Create a custom gem
291
+
292
+ Create a new custom gem with a name, system prompt (instructions), and optional description:
293
+
294
+ ```python
295
+ async def main():
296
+ # Create a new custom gem
297
+ new_gem = await client.create_gem(
298
+ name="Python Tutor",
299
+ prompt="You are a helpful Python programming tutor.",
300
+ description="A specialized gem for Python programming"
301
+ )
302
+
303
+ print(f"Custom gem created: {new_gem}")
304
+
305
+ # Use the newly created gem in a conversation
306
+ response = await client.generate_content(
307
+ "Explain how list comprehensions work in Python",
308
+ gem=new_gem
309
+ )
310
+ print(response.text)
311
+
312
+ asyncio.run(main())
313
+ ```
314
+
315
+ #### Update an existing gem
316
+
317
+ > [!NOTE]
318
+ >
319
+ > When updating a gem, you must provide all parameters (name, prompt, description) even if you only want to change one of them.
320
+
321
+ ```python
322
+ async def main():
323
+ # Get a custom gem (assuming you have one named "Python Tutor")
324
+ await client.fetch_gems()
325
+ python_tutor = client.gems.get(name="Python Tutor")
326
+
327
+ # Update the gem with new instructions
328
+ updated_gem = await client.update_gem(
329
+ gem=python_tutor, # Can also pass gem ID string
330
+ name="Advanced Python Tutor",
331
+ prompt="You are an expert Python programming tutor.",
332
+ description="An advanced Python programming assistant"
333
+ )
334
+
335
+ print(f"Custom gem updated: {updated_gem}")
336
+
337
+ asyncio.run(main())
338
+ ```
339
+
340
+ #### Delete a custom gem
341
+
342
+ ```python
343
+ async def main():
344
+ # Get the gem to delete
345
+ await client.fetch_gems()
346
+ gem_to_delete = client.gems.get(name="Advanced Python Tutor")
347
+
348
+ # Delete the gem
349
+ await client.delete_gem(gem_to_delete) # Can also pass gem ID string
350
+ print(f"Custom gem deleted: {gem_to_delete.name}")
351
+
352
+ asyncio.run(main())
353
+ ```
354
+
282
355
  ### Retrieve model's thought process
283
356
 
284
357
  When using models with thinking capabilities, the model's thought process will be populated in `ModelOutput.thoughts`.
@@ -22,7 +22,7 @@ requires-python = ">=3.10"
22
22
  dependencies = [
23
23
  "httpx[http2]~=0.28.1",
24
24
  "loguru~=0.7.3",
25
- "orjson~=3.10.18",
25
+ "orjson~=3.11.1",
26
26
  "pydantic~=2.11.5",
27
27
  ]
28
28
  dynamic = ["version"]
@@ -1,6 +1,4 @@
1
1
  import asyncio
2
- import functools
3
- import itertools
4
2
  import re
5
3
  from asyncio import Task
6
4
  from pathlib import Path
@@ -9,7 +7,8 @@ from typing import Any, Optional
9
7
  import orjson as json
10
8
  from httpx import AsyncClient, ReadTimeout, Response
11
9
 
12
- from .constants import Endpoint, ErrorCode, Headers, Model, GRPC
10
+ from .components import GemMixin
11
+ from .constants import Endpoint, ErrorCode, Headers, Model
13
12
  from .exceptions import (
14
13
  AuthError,
15
14
  APIError,
@@ -26,7 +25,6 @@ from .types import (
26
25
  Candidate,
27
26
  ModelOutput,
28
27
  Gem,
29
- GemJar,
30
28
  RPCData,
31
29
  )
32
30
  from .utils import (
@@ -35,60 +33,13 @@ from .utils import (
35
33
  rotate_1psidts,
36
34
  get_access_token,
37
35
  load_browser_cookies,
36
+ running,
38
37
  rotate_tasks,
39
38
  logger,
40
39
  )
41
40
 
42
41
 
43
- def running(retry: int = 0) -> callable:
44
- """
45
- Decorator to check if client is running before making a request.
46
-
47
- Parameters
48
- ----------
49
- retry: `int`, optional
50
- Max number of retries when `gemini_webapi.APIError` is raised.
51
- """
52
-
53
- def decorator(func):
54
- @functools.wraps(func)
55
- async def wrapper(client: "GeminiClient", *args, retry=retry, **kwargs):
56
- try:
57
- if not client.running:
58
- await client.init(
59
- timeout=client.timeout,
60
- auto_close=client.auto_close,
61
- close_delay=client.close_delay,
62
- auto_refresh=client.auto_refresh,
63
- refresh_interval=client.refresh_interval,
64
- verbose=False,
65
- )
66
- if client.running:
67
- return await func(client, *args, **kwargs)
68
-
69
- # Should not reach here
70
- raise APIError(
71
- f"Invalid function call: GeminiClient.{func.__name__}. Client initialization failed."
72
- )
73
- else:
74
- return await func(client, *args, **kwargs)
75
- except APIError as e:
76
- # Image generation takes too long, only retry once
77
- if isinstance(e, ImageGenerationError):
78
- retry = min(1, retry)
79
-
80
- if retry > 0:
81
- await asyncio.sleep(1)
82
- return await wrapper(client, *args, retry=retry - 1, **kwargs)
83
-
84
- raise
85
-
86
- return wrapper
87
-
88
- return decorator
89
-
90
-
91
- class GeminiClient:
42
+ class GeminiClient(GemMixin):
92
43
  """
93
44
  Async httpx client interface for gemini.google.com.
94
45
 
@@ -125,7 +76,7 @@ class GeminiClient:
125
76
  "close_task",
126
77
  "auto_refresh",
127
78
  "refresh_interval",
128
- "_gems",
79
+ "_gems", # From GemMixin
129
80
  "kwargs",
130
81
  ]
131
82
 
@@ -136,6 +87,7 @@ class GeminiClient:
136
87
  proxy: str | None = None,
137
88
  **kwargs,
138
89
  ):
90
+ super().__init__()
139
91
  self.cookies = {}
140
92
  self.proxy = proxy
141
93
  self.running: bool = False
@@ -147,7 +99,6 @@ class GeminiClient:
147
99
  self.close_task: Task | None = None
148
100
  self.auto_refresh: bool = True
149
101
  self.refresh_interval: float = 540
150
- self._gems: GemJar | None = None
151
102
  self.kwargs = kwargs
152
103
 
153
104
  # Validate cookies
@@ -285,119 +236,6 @@ class GeminiClient:
285
236
  self.cookies["__Secure-1PSIDTS"] = new_1psidts
286
237
  await asyncio.sleep(self.refresh_interval)
287
238
 
288
- @property
289
- def gems(self) -> GemJar:
290
- """
291
- Returns a `GemJar` object containing cached gems.
292
- Only available after calling `GeminiClient.fetch_gems()`.
293
-
294
- Returns
295
- -------
296
- :class:`GemJar`
297
- Refer to `gemini_webapi.types.GemJar`.
298
-
299
- Raises
300
- ------
301
- `RuntimeError`
302
- If `GeminiClient.fetch_gems()` has not been called before accessing this property.
303
- """
304
-
305
- if self._gems is None:
306
- raise RuntimeError(
307
- "Gems not fetched yet. Call `GeminiClient.fetch_gems()` method to fetch gems from gemini.google.com."
308
- )
309
-
310
- return self._gems
311
-
312
- async def fetch_gems(self, include_hidden: bool = False, **kwargs) -> GemJar:
313
- """
314
- Get a list of available gems from gemini, including system predefined gems and user-created custom gems.
315
-
316
- Note that network request will be sent every time this method is called.
317
- Once the gems are fetched, they will be cached and accessible via `GeminiClient.gems` property.
318
-
319
- Parameters
320
- ----------
321
- include_hidden: `bool`, optional
322
- There are some predefined gems that by default are not shown to users (and therefore may not work properly).
323
- Set this parameter to `True` to include them in the fetched gem list.
324
-
325
- Returns
326
- -------
327
- :class:`GemJar`
328
- Refer to `gemini_webapi.types.GemJar`.
329
- """
330
-
331
- response = await self._batch_execute(
332
- [
333
- RPCData(
334
- rpcid=GRPC.LIST_GEMS,
335
- payload="[4]" if include_hidden else "[3]",
336
- identifier="system",
337
- ),
338
- RPCData(
339
- rpcid=GRPC.LIST_GEMS,
340
- payload="[2]",
341
- identifier="custom",
342
- ),
343
- ],
344
- **kwargs,
345
- )
346
-
347
- try:
348
- response_json = json.loads(response.text.split("\n")[2])
349
-
350
- predefined_gems, custom_gems = [], []
351
-
352
- for part in response_json:
353
- if part[-1] == "system":
354
- predefined_gems = json.loads(part[2])[2]
355
- elif part[-1] == "custom":
356
- if custom_gems_container := json.loads(part[2]):
357
- custom_gems = custom_gems_container[2]
358
-
359
- if not predefined_gems and not custom_gems:
360
- raise Exception
361
- except Exception:
362
- await self.close()
363
- logger.debug(f"Invalid response: {response.text}")
364
- raise APIError(
365
- "Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request."
366
- )
367
-
368
- self._gems = GemJar(
369
- itertools.chain(
370
- (
371
- (
372
- gem[0],
373
- Gem(
374
- id=gem[0],
375
- name=gem[1][0],
376
- description=gem[1][1],
377
- prompt=gem[2] and gem[2][0] or None,
378
- predefined=True,
379
- ),
380
- )
381
- for gem in predefined_gems
382
- ),
383
- (
384
- (
385
- gem[0],
386
- Gem(
387
- id=gem[0],
388
- name=gem[1][0],
389
- description=gem[1][1],
390
- prompt=gem[2] and gem[2][0] or None,
391
- predefined=False,
392
- ),
393
- )
394
- for gem in custom_gems
395
- ),
396
- )
397
- )
398
-
399
- return self._gems
400
-
401
239
  @running(retry=2)
402
240
  async def generate_content(
403
241
  self,
@@ -454,7 +292,9 @@ class GeminiClient:
454
292
  model = Model.from_name(model)
455
293
 
456
294
  if isinstance(gem, Gem):
457
- gem = gem.id
295
+ gem_id = gem.id
296
+ else:
297
+ gem_id = gem
458
298
 
459
299
  if self.auto_close:
460
300
  await self.reset_close_task()
@@ -487,7 +327,7 @@ class GeminiClient:
487
327
  None,
488
328
  chat and chat.metadata,
489
329
  ]
490
- + (gem and [None] * 16 + [gem] or [])
330
+ + (gem_id and [None] * 16 + [gem_id] or [])
491
331
  ).decode(),
492
332
  ]
493
333
  ).decode(),
@@ -611,10 +451,21 @@ class GeminiClient:
611
451
  generated_images = [
612
452
  GeneratedImage(
613
453
  url=generated_image[0][3][3],
614
- title=f"[Generated Image {generated_image[3][6]}]",
615
- alt=len(generated_image[3][5]) > image_index
616
- and generated_image[3][5][image_index]
617
- or generated_image[3][5][0],
454
+ title=(
455
+ f"[Generated Image {generated_image[3][6]}]"
456
+ if generated_image[3][6]
457
+ else "[Generated Image]"
458
+ ),
459
+ alt=(
460
+ generated_image[3][5][image_index]
461
+ if generated_image[3][5]
462
+ and len(generated_image[3][5]) > image_index
463
+ else (
464
+ generated_image[3][5][0]
465
+ if generated_image[3][5]
466
+ else ""
467
+ )
468
+ ),
618
469
  proxy=self.proxy,
619
470
  cookies=self.cookies,
620
471
  )
@@ -667,7 +518,6 @@ class GeminiClient:
667
518
 
668
519
  return ChatSession(geminiclient=self, **kwargs)
669
520
 
670
- @running(retry=2)
671
521
  async def _batch_execute(self, payloads: list[RPCData], **kwargs) -> Response:
672
522
  """
673
523
  Execute a batch of requests to Gemini API.
@@ -703,12 +553,10 @@ class GeminiClient:
703
553
  "consider setting a higher `timeout` value when initializing GeminiClient."
704
554
  )
705
555
 
556
+ # ? Seems like batch execution will immediately invalidate the current access token,
557
+ # ? causing the next request to fail with 401 Unauthorized.
706
558
  if response.status_code != 200:
707
- logger.debug(
708
- f"Batch execution failed with status code {response.status_code}. "
709
- f"RPC: {', '.join({payload.rpcid.name for payload in payloads})}; "
710
- f"Invalid response: {response.text}"
711
- )
559
+ await self.close()
712
560
  raise APIError(
713
561
  f"Batch execution failed with status code {response.status_code}"
714
562
  )
@@ -0,0 +1,3 @@
1
+ # flake8: noqa
2
+
3
+ from .gem_mixin import GemMixin
@@ -0,0 +1,288 @@
1
+ import itertools
2
+
3
+ import orjson as json
4
+
5
+ from ..constants import GRPC
6
+ from ..exceptions import APIError
7
+ from ..types import Gem, GemJar, RPCData
8
+ from ..utils import running, logger
9
+
10
+
11
+ class GemMixin:
12
+ """
13
+ Mixin class providing gem-related functionality for GeminiClient.
14
+ """
15
+
16
+ def __init__(self, *args, **kwargs):
17
+ super().__init__(*args, **kwargs)
18
+ self._gems: GemJar | None = None
19
+
20
+ @property
21
+ def gems(self) -> GemJar:
22
+ """
23
+ Returns a `GemJar` object containing cached gems.
24
+ Only available after calling `GeminiClient.fetch_gems()`.
25
+
26
+ Returns
27
+ -------
28
+ :class:`GemJar`
29
+ Refer to `gemini_webapi.types.GemJar`.
30
+
31
+ Raises
32
+ ------
33
+ `RuntimeError`
34
+ If `GeminiClient.fetch_gems()` has not been called before accessing this property.
35
+ """
36
+
37
+ if self._gems is None:
38
+ raise RuntimeError(
39
+ "Gems not fetched yet. Call `GeminiClient.fetch_gems()` method to fetch gems from gemini.google.com."
40
+ )
41
+
42
+ return self._gems
43
+
44
+ @running(retry=2)
45
+ async def fetch_gems(self, include_hidden: bool = False, **kwargs) -> GemJar:
46
+ """
47
+ Get a list of available gems from gemini, including system predefined gems and user-created custom gems.
48
+
49
+ Note that network request will be sent every time this method is called.
50
+ Once the gems are fetched, they will be cached and accessible via `GeminiClient.gems` property.
51
+
52
+ Parameters
53
+ ----------
54
+ include_hidden: `bool`, optional
55
+ There are some predefined gems that by default are not shown to users (and therefore may not work properly).
56
+ Set this parameter to `True` to include them in the fetched gem list.
57
+
58
+ Returns
59
+ -------
60
+ :class:`GemJar`
61
+ Refer to `gemini_webapi.types.GemJar`.
62
+ """
63
+
64
+ response = await self._batch_execute(
65
+ [
66
+ RPCData(
67
+ rpcid=GRPC.LIST_GEMS,
68
+ payload="[4]" if include_hidden else "[3]",
69
+ identifier="system",
70
+ ),
71
+ RPCData(
72
+ rpcid=GRPC.LIST_GEMS,
73
+ payload="[2]",
74
+ identifier="custom",
75
+ ),
76
+ ],
77
+ **kwargs,
78
+ )
79
+
80
+ try:
81
+ response_json = json.loads(response.text.split("\n")[2])
82
+
83
+ predefined_gems, custom_gems = [], []
84
+
85
+ for part in response_json:
86
+ if part[-1] == "system":
87
+ predefined_gems = json.loads(part[2])[2]
88
+ elif part[-1] == "custom":
89
+ if custom_gems_container := json.loads(part[2]):
90
+ custom_gems = custom_gems_container[2]
91
+
92
+ if not predefined_gems and not custom_gems:
93
+ raise Exception
94
+ except Exception:
95
+ await self.close()
96
+ logger.debug(f"Invalid response: {response.text}")
97
+ raise APIError(
98
+ "Failed to fetch gems. Invalid response data received. Client will try to re-initialize on next request."
99
+ )
100
+
101
+ self._gems = GemJar(
102
+ itertools.chain(
103
+ (
104
+ (
105
+ gem[0],
106
+ Gem(
107
+ id=gem[0],
108
+ name=gem[1][0],
109
+ description=gem[1][1],
110
+ prompt=gem[2] and gem[2][0] or None,
111
+ predefined=True,
112
+ ),
113
+ )
114
+ for gem in predefined_gems
115
+ ),
116
+ (
117
+ (
118
+ gem[0],
119
+ Gem(
120
+ id=gem[0],
121
+ name=gem[1][0],
122
+ description=gem[1][1],
123
+ prompt=gem[2] and gem[2][0] or None,
124
+ predefined=False,
125
+ ),
126
+ )
127
+ for gem in custom_gems
128
+ ),
129
+ )
130
+ )
131
+
132
+ return self._gems
133
+
134
+ @running(retry=2)
135
+ async def create_gem(self, name: str, prompt: str, description: str = "") -> Gem:
136
+ """
137
+ Create a new custom gem.
138
+
139
+ Parameters
140
+ ----------
141
+ name: `str`
142
+ Name of the custom gem.
143
+ prompt: `str`
144
+ System instructions for the custom gem.
145
+ description: `str`, optional
146
+ Description of the custom gem (has no effect on the model's behavior).
147
+
148
+ Returns
149
+ -------
150
+ :class:`Gem`
151
+ The created gem.
152
+ """
153
+
154
+ response = await self._batch_execute(
155
+ [
156
+ RPCData(
157
+ rpcid=GRPC.CREATE_GEM,
158
+ payload=json.dumps(
159
+ [
160
+ [
161
+ name,
162
+ description,
163
+ prompt,
164
+ None,
165
+ None,
166
+ None,
167
+ None,
168
+ None,
169
+ 0,
170
+ None,
171
+ 1,
172
+ None,
173
+ None,
174
+ None,
175
+ [],
176
+ ]
177
+ ]
178
+ ).decode(),
179
+ )
180
+ ]
181
+ )
182
+
183
+ try:
184
+ response_json = json.loads(response.text.split("\n")[2])
185
+ gem_id = json.loads(response_json[0][2])[0]
186
+ except Exception:
187
+ await self.close()
188
+ logger.debug(f"Invalid response: {response.text}")
189
+ raise APIError(
190
+ "Failed to create gem. Invalid response data received. Client will try to re-initialize on next request."
191
+ )
192
+
193
+ return Gem(
194
+ id=gem_id,
195
+ name=name,
196
+ description=description,
197
+ prompt=prompt,
198
+ predefined=False,
199
+ )
200
+
201
+ @running(retry=2)
202
+ async def update_gem(
203
+ self, gem: Gem | str, name: str, prompt: str, description: str = ""
204
+ ) -> Gem:
205
+ """
206
+ Update an existing custom gem.
207
+
208
+ Parameters
209
+ ----------
210
+ gem: `Gem | str`
211
+ Gem to update, can be either a `gemini_webapi.types.Gem` object or a gem id string.
212
+ name: `str`
213
+ New name for the custom gem.
214
+ prompt: `str`
215
+ New system instructions for the custom gem.
216
+ description: `str`, optional
217
+ New description of the custom gem (has no effect on the model's behavior).
218
+
219
+ Returns
220
+ -------
221
+ :class:`Gem`
222
+ The updated gem.
223
+ """
224
+
225
+ if isinstance(gem, Gem):
226
+ gem_id = gem.id
227
+ else:
228
+ gem_id = gem
229
+
230
+ await self._batch_execute(
231
+ [
232
+ RPCData(
233
+ rpcid=GRPC.UPDATE_GEM,
234
+ payload=json.dumps(
235
+ [
236
+ gem_id,
237
+ [
238
+ name,
239
+ description,
240
+ prompt,
241
+ None,
242
+ None,
243
+ None,
244
+ None,
245
+ None,
246
+ 0,
247
+ None,
248
+ 1,
249
+ None,
250
+ None,
251
+ None,
252
+ [],
253
+ 0,
254
+ ],
255
+ ]
256
+ ).decode(),
257
+ )
258
+ ]
259
+ )
260
+
261
+ return Gem(
262
+ id=gem_id,
263
+ name=name,
264
+ description=description,
265
+ prompt=prompt,
266
+ predefined=False,
267
+ )
268
+
269
+ @running(retry=2)
270
+ async def delete_gem(self, gem: Gem | str, **kwargs) -> None:
271
+ """
272
+ Delete a custom gem.
273
+
274
+ Parameters
275
+ ----------
276
+ gem: `Gem | str`
277
+ Gem to delete, can be either a `gemini_webapi.types.Gem` object or a gem id string.
278
+ """
279
+
280
+ if isinstance(gem, Gem):
281
+ gem_id = gem.id
282
+ else:
283
+ gem_id = gem
284
+
285
+ await self._batch_execute(
286
+ [RPCData(rpcid=GRPC.DELETE_GEM, payload=json.dumps([gem_id]).decode())],
287
+ **kwargs,
288
+ )
@@ -20,6 +20,9 @@ class GRPC(StrEnum):
20
20
 
21
21
  # Gem methods
22
22
  LIST_GEMS = "CNgdBe"
23
+ CREATE_GEM = "oMH3Zd"
24
+ UPDATE_GEM = "kHv0Vd"
25
+ DELETE_GEM = "UXcSJb"
23
26
 
24
27
 
25
28
  class Headers(Enum):
@@ -30,10 +30,10 @@ class Image(BaseModel):
30
30
  proxy: str | None = None
31
31
 
32
32
  def __str__(self):
33
- return f"{self.title}({self.url}) - {self.alt}"
34
-
35
- def __repr__(self):
36
- return f"Image(title='{self.title}', url='{len(self.url) <= 20 and self.url or self.url[:8] + '...' + self.url[-12:]}', alt='{self.alt}')"
33
+ return (
34
+ f"Image(title='{self.title}', alt='{self.alt}', "
35
+ f"url='{len(self.url) <= 20 and self.url or self.url[:8] + '...' + self.url[-12:]}')"
36
+ )
37
37
 
38
38
  async def save(
39
39
  self,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from asyncio import Task
4
4
 
5
+ from .decorators import running
5
6
  from .upload_file import upload_file, parse_file_name
6
7
  from .rotate_1psidts import rotate_1psidts
7
8
  from .get_access_token import get_access_token
@@ -0,0 +1,52 @@
1
+ import asyncio
2
+ import functools
3
+
4
+ from ..exceptions import APIError, ImageGenerationError
5
+
6
+
7
+ def running(retry: int = 0) -> callable:
8
+ """
9
+ Decorator to check if GeminiClient is running before making a request.
10
+
11
+ Parameters
12
+ ----------
13
+ retry: `int`, optional
14
+ Max number of retries when `gemini_webapi.APIError` is raised.
15
+ """
16
+
17
+ def decorator(func):
18
+ @functools.wraps(func)
19
+ async def wrapper(client, *args, retry=retry, **kwargs):
20
+ try:
21
+ if not client.running:
22
+ await client.init(
23
+ timeout=client.timeout,
24
+ auto_close=client.auto_close,
25
+ close_delay=client.close_delay,
26
+ auto_refresh=client.auto_refresh,
27
+ refresh_interval=client.refresh_interval,
28
+ verbose=False,
29
+ )
30
+ if client.running:
31
+ return await func(client, *args, **kwargs)
32
+
33
+ # Should not reach here
34
+ raise APIError(
35
+ f"Invalid function call: GeminiClient.{func.__name__}. Client initialization failed."
36
+ )
37
+ else:
38
+ return await func(client, *args, **kwargs)
39
+ except APIError as e:
40
+ # Image generation takes too long, only retry once
41
+ if isinstance(e, ImageGenerationError):
42
+ retry = min(1, retry)
43
+
44
+ if retry > 0:
45
+ await asyncio.sleep(1)
46
+ return await wrapper(client, *args, retry=retry - 1, **kwargs)
47
+
48
+ raise
49
+
50
+ return wrapper
51
+
52
+ return decorator
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gemini-webapi
3
- Version: 1.14.3
3
+ Version: 1.15.0
4
4
  Summary: ✨ An elegant async Python wrapper for Google Gemini web app
5
5
  Author: UZQueen
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -678,7 +678,7 @@ Description-Content-Type: text/markdown
678
678
  License-File: LICENSE
679
679
  Requires-Dist: httpx[http2]~=0.28.1
680
680
  Requires-Dist: loguru~=0.7.3
681
- Requires-Dist: orjson~=3.10.18
681
+ Requires-Dist: orjson~=3.11.1
682
682
  Requires-Dist: pydantic~=2.11.5
683
683
  Dynamic: license-file
684
684
 
@@ -734,6 +734,10 @@ A reverse-engineered asynchronous python wrapper for [Google Gemini](https://gem
734
734
  - [Continue previous conversations](#continue-previous-conversations)
735
735
  - [Select language model](#select-language-model)
736
736
  - [Apply system prompt with Gemini Gems](#apply-system-prompt-with-gemini-gems)
737
+ - [Manage Custom Gems](#manage-custom-gems)
738
+ - [Create a custom gem](#create-a-custom-gem)
739
+ - [Update an existing gem](#update-an-existing-gem)
740
+ - [Delete a custom gem](#delete-a-custom-gem)
737
741
  - [Retrieve model's thought process](#retrieve-models-thought-process)
738
742
  - [Retrieve images in response](#retrieve-images-in-response)
739
743
  - [Generate images with Imagen4](#generate-images-with-imagen4)
@@ -963,6 +967,75 @@ async def main():
963
967
  print(response2)
964
968
  ```
965
969
 
970
+ ### Manage Custom Gems
971
+
972
+ You can create, update, and delete your custom gems programmatically with the API. Note that predefined system gems cannot be modified or deleted.
973
+
974
+ #### Create a custom gem
975
+
976
+ Create a new custom gem with a name, system prompt (instructions), and optional description:
977
+
978
+ ```python
979
+ async def main():
980
+ # Create a new custom gem
981
+ new_gem = await client.create_gem(
982
+ name="Python Tutor",
983
+ prompt="You are a helpful Python programming tutor.",
984
+ description="A specialized gem for Python programming"
985
+ )
986
+
987
+ print(f"Custom gem created: {new_gem}")
988
+
989
+ # Use the newly created gem in a conversation
990
+ response = await client.generate_content(
991
+ "Explain how list comprehensions work in Python",
992
+ gem=new_gem
993
+ )
994
+ print(response.text)
995
+
996
+ asyncio.run(main())
997
+ ```
998
+
999
+ #### Update an existing gem
1000
+
1001
+ > [!NOTE]
1002
+ >
1003
+ > When updating a gem, you must provide all parameters (name, prompt, description) even if you only want to change one of them.
1004
+
1005
+ ```python
1006
+ async def main():
1007
+ # Get a custom gem (assuming you have one named "Python Tutor")
1008
+ await client.fetch_gems()
1009
+ python_tutor = client.gems.get(name="Python Tutor")
1010
+
1011
+ # Update the gem with new instructions
1012
+ updated_gem = await client.update_gem(
1013
+ gem=python_tutor, # Can also pass gem ID string
1014
+ name="Advanced Python Tutor",
1015
+ prompt="You are an expert Python programming tutor.",
1016
+ description="An advanced Python programming assistant"
1017
+ )
1018
+
1019
+ print(f"Custom gem updated: {updated_gem}")
1020
+
1021
+ asyncio.run(main())
1022
+ ```
1023
+
1024
+ #### Delete a custom gem
1025
+
1026
+ ```python
1027
+ async def main():
1028
+ # Get the gem to delete
1029
+ await client.fetch_gems()
1030
+ gem_to_delete = client.gems.get(name="Advanced Python Tutor")
1031
+
1032
+ # Delete the gem
1033
+ await client.delete_gem(gem_to_delete) # Can also pass gem ID string
1034
+ print(f"Custom gem deleted: {gem_to_delete.name}")
1035
+
1036
+ asyncio.run(main())
1037
+ ```
1038
+
966
1039
  ### Retrieve model's thought process
967
1040
 
968
1041
  When using models with thinking capabilities, the model's thought process will be populated in `ModelOutput.thoughts`.
@@ -20,6 +20,8 @@ src/gemini_webapi.egg-info/SOURCES.txt
20
20
  src/gemini_webapi.egg-info/dependency_links.txt
21
21
  src/gemini_webapi.egg-info/requires.txt
22
22
  src/gemini_webapi.egg-info/top_level.txt
23
+ src/gemini_webapi/components/__init__.py
24
+ src/gemini_webapi/components/gem_mixin.py
23
25
  src/gemini_webapi/types/__init__.py
24
26
  src/gemini_webapi/types/candidate.py
25
27
  src/gemini_webapi/types/gem.py
@@ -27,11 +29,12 @@ src/gemini_webapi/types/grpc.py
27
29
  src/gemini_webapi/types/image.py
28
30
  src/gemini_webapi/types/modeloutput.py
29
31
  src/gemini_webapi/utils/__init__.py
32
+ src/gemini_webapi/utils/decorators.py
30
33
  src/gemini_webapi/utils/get_access_token.py
31
34
  src/gemini_webapi/utils/load_browser_cookies.py
32
35
  src/gemini_webapi/utils/logger.py
33
36
  src/gemini_webapi/utils/rotate_1psidts.py
34
37
  src/gemini_webapi/utils/upload_file.py
35
38
  tests/test_client_features.py
36
- tests/test_rotate_cookies.py
39
+ tests/test_gem_mixin.py
37
40
  tests/test_save_image.py
@@ -1,4 +1,4 @@
1
1
  httpx[http2]~=0.28.1
2
2
  loguru~=0.7.3
3
- orjson~=3.10.18
3
+ orjson~=3.11.1
4
4
  pydantic~=2.11.5
@@ -72,7 +72,6 @@ class TestGeminiClient(unittest.IsolatedAsyncioTestCase):
72
72
  self.assertTrue(response.images)
73
73
  logger.debug(response.text)
74
74
  for image in response.images:
75
- self.assertTrue(image.url)
76
75
  logger.debug(image)
77
76
 
78
77
  @logger.catch(reraise=True)
@@ -83,7 +82,17 @@ class TestGeminiClient(unittest.IsolatedAsyncioTestCase):
83
82
  self.assertTrue(response.images)
84
83
  logger.debug(response.text)
85
84
  for image in response.images:
86
- self.assertTrue(image.url)
85
+ logger.debug(image)
86
+
87
+ @logger.catch(reraise=True)
88
+ async def test_image_to_image(self):
89
+ response = await self.geminiclient.generate_content(
90
+ "Design an application icon based on the provided image. Make it simple and modern.",
91
+ files=["assets/banner.png"],
92
+ )
93
+ self.assertTrue(response.images)
94
+ logger.debug(response.text)
95
+ for image in response.images:
87
96
  logger.debug(image)
88
97
 
89
98
  @logger.catch(reraise=True)
@@ -95,14 +104,6 @@ class TestGeminiClient(unittest.IsolatedAsyncioTestCase):
95
104
  )
96
105
  logger.debug(response.text)
97
106
 
98
- @logger.catch(reraise=True)
99
- async def test_fetch_gems(self):
100
- await self.geminiclient.fetch_gems(include_hidden=True)
101
- gems = self.geminiclient.gems
102
- self.assertTrue(len(gems.filter(predefined=True)) > 0)
103
- for gem in gems:
104
- logger.debug(gem.name)
105
-
106
107
  @logger.catch(reraise=True)
107
108
  async def test_thinking_model(self):
108
109
  response = await self.geminiclient.generate_content(
@@ -0,0 +1,88 @@
1
+ import os
2
+ import random
3
+ import unittest
4
+ import logging
5
+
6
+ from gemini_webapi import GeminiClient, set_log_level, logger
7
+ from gemini_webapi.exceptions import AuthError
8
+
9
+ logging.getLogger("asyncio").setLevel(logging.ERROR)
10
+ set_log_level("DEBUG")
11
+
12
+
13
+ class TestGemMixin(unittest.IsolatedAsyncioTestCase):
14
+ async def asyncSetUp(self):
15
+ self.geminiclient = GeminiClient(
16
+ os.getenv("SECURE_1PSID"), os.getenv("SECURE_1PSIDTS"), verify=False
17
+ )
18
+
19
+ try:
20
+ await self.geminiclient.init(timeout=60, auto_refresh=False)
21
+ except AuthError as e:
22
+ self.skipTest(e)
23
+
24
+ @logger.catch(reraise=True)
25
+ async def test_fetch_gems(self):
26
+ await self.geminiclient.fetch_gems(include_hidden=True)
27
+ gems = self.geminiclient.gems
28
+ self.assertTrue(len(gems.filter(predefined=True)) > 0)
29
+ for gem in gems:
30
+ logger.debug(gem.name)
31
+
32
+ custom_gems = gems.filter(predefined=False)
33
+ if custom_gems:
34
+ logger.debug(f"Found {len(custom_gems)} custom gems:")
35
+ for gem in custom_gems:
36
+ logger.debug(gem)
37
+
38
+ @logger.catch(reraise=True)
39
+ async def test_create_gem(self):
40
+ gem = await self.geminiclient.create_gem(
41
+ name="Test Gem",
42
+ prompt="Gemini API has launched creating gem functionality on Aug 1st, 2025",
43
+ description="This gem is used for testing the functionality of Gemini API",
44
+ )
45
+ logger.debug(f"Gem created: {gem}")
46
+
47
+ @logger.catch(reraise=True)
48
+ async def test_update_gem(self):
49
+ await self.geminiclient.fetch_gems()
50
+ custom_gems = self.geminiclient.gems.filter(predefined=False)
51
+ if not custom_gems:
52
+ self.skipTest("No custom gems available to update.")
53
+
54
+ last_created_gem = next(iter(custom_gems.values()))
55
+ randint = random.randint(0, 100)
56
+ updated_gem = await self.geminiclient.update_gem(
57
+ last_created_gem.id,
58
+ name="Updated Test Gem",
59
+ prompt="Updated prompt for the gem.",
60
+ description=f"{randint}",
61
+ )
62
+ logger.debug(f"Gem updated: {updated_gem}")
63
+
64
+ await self.geminiclient.fetch_gems()
65
+ custom_gems = self.geminiclient.gems.filter(predefined=False)
66
+ last_created_gem = next(iter(custom_gems.values()))
67
+ self.assertEqual(last_created_gem.description, updated_gem.description)
68
+
69
+ @logger.catch(reraise=True)
70
+ async def test_delete_gem(self):
71
+ await self.geminiclient.fetch_gems()
72
+ custom_gems = self.geminiclient.gems.filter(predefined=False)
73
+ total_before_deletion = len(custom_gems)
74
+ if total_before_deletion == 0:
75
+ self.skipTest("No custom gems available to delete.")
76
+
77
+ last_created_gem = next(iter(custom_gems.values()))
78
+ await self.geminiclient.delete_gem(last_created_gem.id)
79
+ logger.debug(f"Gem deleted: {last_created_gem}")
80
+
81
+ await self.geminiclient.fetch_gems()
82
+ custom_gems = self.geminiclient.gems.filter(predefined=False)
83
+ total_after_deletion = len(custom_gems)
84
+ self.assertEqual(total_after_deletion, total_before_deletion - 1)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ unittest.main()
@@ -26,8 +26,7 @@ class TestGeminiClient(unittest.IsolatedAsyncioTestCase):
26
26
  "Show me some pictures of random subjects"
27
27
  )
28
28
  self.assertTrue(response.images)
29
- for i, image in enumerate(response.images):
30
- self.assertTrue(image.url)
29
+ for image in response.images:
31
30
  try:
32
31
  await image.save(verbose=True, skip_invalid_filename=True)
33
32
  except HTTPError as e:
@@ -38,8 +37,16 @@ class TestGeminiClient(unittest.IsolatedAsyncioTestCase):
38
37
  "Generate a picture of random subjects"
39
38
  )
40
39
  self.assertTrue(response.images)
41
- for i, image in enumerate(response.images):
42
- self.assertTrue(image.url)
40
+ for image in response.images:
41
+ await image.save(verbose=True, full_size=True)
42
+
43
+ async def test_save_image_to_image(self):
44
+ response = await self.geminiclient.generate_content(
45
+ "Design an application icon based on the provided image. Make it simple and modern.",
46
+ files=["assets/banner.png"],
47
+ )
48
+ self.assertTrue(response.images)
49
+ for image in response.images:
43
50
  await image.save(verbose=True, full_size=True)
44
51
 
45
52
 
@@ -1,24 +0,0 @@
1
- import os
2
- import asyncio
3
-
4
- from gemini_webapi import GeminiClient, set_log_level, logger
5
-
6
- set_log_level("DEBUG")
7
-
8
-
9
- @logger.catch()
10
- async def main():
11
- client = GeminiClient(os.getenv("SECURE_1PSID"), os.getenv("SECURE_1PSIDTS"))
12
- await client.init(close_delay=30, refresh_interval=60)
13
-
14
- while True:
15
- try:
16
- response = await client.generate_content("Hello world")
17
- logger.info(response)
18
- except Exception as e:
19
- logger.error(e)
20
- await asyncio.sleep(60)
21
-
22
-
23
- if __name__ == "__main__":
24
- asyncio.run(main())
File without changes
File without changes