返回提交历史
Modified
g4f/Provider/needs_auth/OpenaiChat.py
+46
-18
Modified
g4f/client/__init__.py
+5
-1
Modified
g4f/gui/client/static/css/style.css
+20
-4
Modified
g4f/gui/client/static/js/chat.v1.js
+120
-49
Modified
g4f/gui/server/api.py
+3
-1
Modified
g4f/gui/server/backend.py
+50
-1
Modified
g4f/providers/base_provider.py
+2
-2
Modified
g4f/providers/response.py
+13
-0
XFEstudio/gpt4free
Support synthesize in Openai generator (#2394)
* Improve download of generated images, serve images in the api * Add support for conversation handling in the api * Add orginal prompt to image response * Add download images option in gui, fix loading model list in Airforce * Support speech synthesize in Openai generator
eae317a1
代码差异
8 个文件
+259
-76
@@ -7,7 +7,6 @@ import json
7
7
import base64
8
8
import time
9
9
import requests
10
from aiohttp import ClientWebSocketResponse
11
10
from copy import copy
12
11
13
12
try:
@@ -28,7 +27,7 @@ from ...requests.raise_for_status import raise_for_status
28
27
from ...requests.aiohttp import StreamSession
29
28
from ...image import ImageResponse, ImageRequest, to_image, to_bytes, is_accepted_format
30
29
from ...errors import MissingAuthError, ResponseError
31
from ...providers.response import BaseConversation
30
from ...providers.response import BaseConversation, FinishReason, SynthesizeData
32
31
from ..helper import format_cookies
33
32
from ..openai.har_file import get_request_config, NoValidHarFileError
34
33
from ..openai.har_file import RequestConfig, arkReq, arkose_url, start_url, conversation_url, backend_url, backend_anon_url
@@ -367,19 +366,13 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
367
366
Raises:
368
367
RuntimeError: If an error occurs during processing.
369
368
"""
369
await cls.login(proxy)
370
370
371
async with StreamSession(
371
372
proxy=proxy,
372
373
impersonate="chrome",
373
374
timeout=timeout
374
375
) as session:
375
if cls._expires is not None and cls._expires < time.time():
376
cls._headers = cls._api_key = None
377
try:
378
await get_request_config(proxy)
379
cls._create_request_args(RequestConfig.cookies, RequestConfig.headers)
380
cls._set_api_key(RequestConfig.access_token)
381
except NoValidHarFileError as e:
382
await cls.nodriver_auth(proxy)
383
376
try:
384
377
image_request = await cls.upload_image(session, cls._headers, image, image_name) if image else None
385
378
except Exception as e:
@@ -469,18 +462,25 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
469
462
await asyncio.sleep(5)
470
463
continue
471
464
await raise_for_status(response)
472
async for chunk in cls.iter_messages_chunk(response.iter_lines(), session, conversation):
473
if return_conversation:
474
history_disabled = False
475
return_conversation = False
476
yield conversation
477
yield chunk
465
if return_conversation:
466
history_disabled = False
467
yield conversation
468
async for line in response.iter_lines():
469
async for chunk in cls.iter_messages_line(session, line, conversation):
470
yield chunk
471
if not history_disabled:
472
yield SynthesizeData(cls.__name__, {
473
"conversation_id": conversation.conversation_id,
474
"message_id": conversation.message_id,
475
"voice": "maple",
476
})
478
477
if auto_continue and conversation.finish_reason == "max_tokens":
479
478
conversation.finish_reason = None
480
479
action = "continue"
481
480
await asyncio.sleep(5)
482
481
else:
483
482
break
483
yield FinishReason(conversation.finish_reason)
484
484
if history_disabled and auto_continue:
485
485
await cls.delete_conversation(session, cls._headers, conversation.conversation_id)
486
486
@@ -541,10 +541,38 @@ class OpenaiChat(AsyncGeneratorProvider, ProviderModelMixin):
541
541
if "error" in line and line.get("error"):
542
542
raise RuntimeError(line.get("error"))
543
543
544
@classmethod
545
async def synthesize(cls, params: dict) -> AsyncIterator[bytes]:
546
await cls.login()
547
async with StreamSession(
548
impersonate="chrome",
549
timeout=900
550
) as session:
551
async with session.get(
552
f"{cls.url}/backend-api/synthesize",
553
params=params,
554
headers=cls._headers
555
) as response:
556
await raise_for_status(response)
557
async for chunk in response.iter_content():
558
yield chunk
559
560
@classmethod
561
async def login(cls, proxy: str = None):
562
if cls._expires is not None and cls._expires < time.time():
563
cls._headers = cls._api_key = None
564
try:
565
await get_request_config(proxy)
566
cls._create_request_args(RequestConfig.cookies, RequestConfig.headers)
567
cls._set_api_key(RequestConfig.access_token)
568
except NoValidHarFileError:
569
if has_nodriver:
570
await cls.nodriver_auth(proxy)
571
else:
572
raise
573
544
574
@classmethod
545
575
async def nodriver_auth(cls, proxy: str = None):
546
if not has_nodriver:
547
return
548
576
if has_platformdirs:
549
577
user_data_dir = user_config_dir("g4f-nodriver")
550
578
else:
@@ -13,7 +13,7 @@ from ..providers.base_provider import AsyncGeneratorProvider
13
13
from ..image import ImageResponse, copy_images, images_dir
14
14
from ..typing import Messages, Image, ImageType
15
15
from ..providers.types import ProviderType
16
from ..providers.response import ResponseType, FinishReason, BaseConversation
16
from ..providers.response import ResponseType, FinishReason, BaseConversation, SynthesizeData
17
17
from ..errors import NoImageResponseError, ModelNotFoundError
18
18
from ..providers.retry_provider import IterListProvider
19
19
from ..providers.base_provider import get_running_loop
@@ -60,6 +60,8 @@ def iter_response(
60
60
elif isinstance(chunk, BaseConversation):
61
61
yield chunk
62
62
continue
63
elif isinstance(chunk, SynthesizeData):
64
continue
63
65
64
66
chunk = str(chunk)
65
67
content += chunk
@@ -121,6 +123,8 @@ async def async_iter_response(
121
123
elif isinstance(chunk, BaseConversation):
122
124
yield chunk
123
125
continue
126
elif isinstance(chunk, SynthesizeData):
127
continue
124
128
125
129
chunk = str(chunk)
126
130
content += chunk
@@ -259,7 +259,6 @@ body {
259
259
flex-direction: column;
260
260
gap: var(--section-gap);
261
261
padding: var(--inner-gap) var(--section-gap);
262
padding-bottom: 0;
263
262
}
264
263
265
264
.message.print {
@@ -271,7 +270,11 @@ body {
271
270
}
272
271
273
272
.message.regenerate {
274
opacity: 0.75;
273
background-color: var(--colour-6);
274
}
275
276
.white .message.regenerate {
277
background-color: var(--colour-4);
275
278
}
276
279
277
280
.message:last-child {
@@ -407,6 +410,7 @@ body {
407
410
408
411
.message .count .fa-clipboard.clicked,
409
412
.message .count .fa-print.clicked,
413
.message .count .fa-rotate.clicked,
410
414
.message .count .fa-volume-high.active {
411
415
color: var(--accent);
412
416
}
@@ -430,6 +434,15 @@ body {
430
434
font-size: 12px;
431
435
}
432
436
437
.message audio {
438
display: none;
439
max-width: 400px;
440
}
441
442
.message audio.show {
443
display: block;
444
}
445
433
446
.count_total {
434
447
font-size: 12px;
435
448
padding-left: 25px;
@@ -1159,7 +1172,10 @@ a:-webkit-any-link {
1159
1172
.message .user {
1160
1173
display: none;
1161
1174
}
1162
.message.regenerate {
1163
opacity: 1;
1175
body {
1176
height: auto;
1177
}
1178
.box {
1179
backdrop-filter: none;
1164
1180
}
1165
1181
}
@@ -28,6 +28,7 @@ let message_storage = {};
28
28
let controller_storage = {};
29
29
let content_storage = {};
30
30
let error_storage = {};
31
let synthesize_storage = {};
31
32
32
33
messageInput.addEventListener("blur", () => {
33
34
window.scrollTo(0, 0);
@@ -134,6 +135,13 @@ const register_message_buttons = async () => {
134
135
if (!("click" in el.dataset)) {
135
136
el.dataset.click = "true";
136
137
el.addEventListener("click", async () => {
138
const content_el = el.parentElement.parentElement;
139
const audio = content_el.querySelector("audio");
140
if (audio) {
141
audio.classList.add("show");
142
audio.play();
143
return;
144
}
137
145
let playlist = [];
138
146
function play_next() {
139
147
const next = playlist.shift();
@@ -155,7 +163,6 @@ const register_message_buttons = async () => {
155
163
el.dataset.running = true;
156
164
el.classList.add("blink")
157
165
el.classList.add("active")
158
const content_el = el.parentElement.parentElement;
159
166
const message_el = content_el.parentElement;
160
167
let speechText = await get_message(window.conversation_id, message_el.dataset.index);
161
168
@@ -215,8 +222,8 @@ const register_message_buttons = async () => {
215
222
const message_el = el.parentElement.parentElement.parentElement;
216
223
el.classList.add("clicked");
217
224
setTimeout(() => el.classList.remove("clicked"), 1000);
218
await ask_gpt(message_el.dataset.index, get_message_id());
219
})
225
await ask_gpt(get_message_id(), message_el.dataset.index);
226
});
220
227
}
221
228
});
222
229
document.querySelectorAll(".message .fa-whatsapp").forEach(async (el) => {
@@ -301,25 +308,29 @@ const handle_ask = async () => {
301
308
<i class="fa-regular fa-clipboard"></i>
302
309
<a><i class="fa-brands fa-whatsapp"></i></a>
303
310
<i class="fa-solid fa-print"></i>
311
<i class="fa-solid fa-rotate"></i>
304
312
</div>
305
313
</div>
306
314
</div>
307
315
`;
308
316
highlight(message_box);
309
stop_generating.classList.remove("stop_generating-hidden");
310
await ask_gpt(-1, message_id);
317
await ask_gpt(message_id);
311
318
};
312
319
313
async function remove_cancel_button() {
320
async function safe_remove_cancel_button() {
321
for (let key in controller_storage) {
322
if (!controller_storage[key].signal.aborted) {
323
return;
324
}
325
}
314
326
stop_generating.classList.add("stop_generating-hidden");
315
327
}
316
328
317
329
regenerate.addEventListener("click", async () => {
318
330
regenerate.classList.add("regenerate-hidden");
319
331
setTimeout(()=>regenerate.classList.remove("regenerate-hidden"), 3000);
320
stop_generating.classList.remove("stop_generating-hidden");
321
332
await hide_message(window.conversation_id);
322
await ask_gpt(-1, get_message_id());
333
await ask_gpt(get_message_id());
323
334
});
324
335
325
336
stop_generating.addEventListener("click", async () => {
@@ -337,21 +348,21 @@ stop_generating.addEventListener("click", async () => {
337
348
}
338
349
}
339
350
}
340
await load_conversation(window.conversation_id);
351
await load_conversation(window.conversation_id, false);
341
352
});
342
353
343
354
const prepare_messages = (messages, message_index = -1) => {
355
if (message_index >= 0) {
356
messages = messages.filter((_, index) => message_index >= index);
357
}
358
344
359
// Removes none user messages at end
345
if (message_index == -1) {
346
let last_message;
347
while (last_message = messages.pop()) {
348
if (last_message["role"] == "user") {
349
messages.push(last_message);
350
break;
351
}
360
let last_message;
361
while (last_message = messages.pop()) {
362
if (last_message["role"] == "user") {
363
messages.push(last_message);
364
break;
352
365
}
353
} else if (message_index >= 0) {
354
messages = messages.filter((_, index) => message_index >= index);
355
366
}
356
367
357
368
let new_messages = [];
@@ -377,9 +388,11 @@ const prepare_messages = (messages, message_index = -1) => {
377
388
// Remove generated images from history
378
389
new_message.content = filter_message(new_message.content);
379
390
delete new_message.provider;
391
delete new_message.synthesize;
380
392
new_messages.push(new_message)
381
393
}
382
394
});
395
383
396
return new_messages;
384
397
}
385
398
@@ -427,6 +440,8 @@ async function add_message_chunk(message, message_id) {
427
440
let p = document.createElement("p");
428
441
p.innerText = message.log;
429
442
log_storage.appendChild(p);
443
} else if (message.type == "synthesize") {
444
synthesize_storage[message_id] = message.synthesize;
430
445
}
431
446
let scroll_down = ()=>{
432
447
if (message_box.scrollTop >= message_box.scrollHeight - message_box.clientHeight - 100) {
@@ -434,8 +449,10 @@ async function add_message_chunk(message, message_id) {
434
449
message_box.scrollTo({ top: message_box.scrollHeight, behavior: "auto" });
435
450
}
436
451
}
437
setTimeout(scroll_down, 200);
438
setTimeout(scroll_down, 1000);
452
if (!content_map.container.classList.contains("regenerate")) {
453
scroll_down();
454
setTimeout(scroll_down, 200);
455
}
439
456
}
440
457
441
458
cameraInput?.addEventListener("click", (e) => {
@@ -452,45 +469,58 @@ imageInput?.addEventListener("click", (e) => {
452
469
}
453
470
});
454
471
455
const ask_gpt = async (message_index = -1, message_id) => {
472
const ask_gpt = async (message_id, message_index = -1) => {
456
473
let messages = await get_messages(window.conversation_id);
457
let total_messages = messages.length;
458
474
messages = prepare_messages(messages, message_index);
459
message_index = total_messages
460
475
message_storage[message_id] = "";
461
stop_generating.classList.remove(".stop_generating-hidden");
476
stop_generating.classList.remove("stop_generating-hidden");
462
477
463
message_box.scrollTop = message_box.scrollHeight;
464
window.scrollTo(0, 0);
478
if (message_index == -1) {
479
await scroll_to_bottom();
480
}
465
481
466
482
let count_total = message_box.querySelector('.count_total');
467
483
count_total ? count_total.parentElement.removeChild(count_total) : null;
468
484
469
message_box.innerHTML += `
470
<div class="message" data-index="${message_index}">
471
<div class="assistant">
472
${gpt_image}
473
<i class="fa-solid fa-xmark"></i>
474
<i class="fa-regular fa-phone-arrow-down-left"></i>
475
</div>
476
<div class="content" id="gpt_${message_id}">
477
<div class="provider"></div>
478
<div class="content_inner"><span class="cursor"></span></div>
479
<div class="count"></div>
480
</div>
485
const message_el = document.createElement("div");
486
message_el.classList.add("message");
487
if (message_index != -1) {
488
message_el.classList.add("regenerate");
489
}
490
message_el.innerHTML += `
491
<div class="assistant">
492
${gpt_image}
493
<i class="fa-solid fa-xmark"></i>
494
<i class="fa-regular fa-phone-arrow-down-left"></i>
495
</div>
496
<div class="content" id="gpt_${message_id}">
497
<div class="provider"></div>
498
<div class="content_inner"><span class="cursor"></span></div>
499
<div class="count"></div>
481
500
</div>
482
501
`;
502
if (message_index == -1) {
503
message_box.appendChild(message_el);
504
} else {
505
parent_message = message_box.querySelector(`.message[data-index="${message_index}"]`);
506
if (!parent_message) {
507
return;
508
}
509
parent_message.after(message_el);
510
}
483
511
484
512
controller_storage[message_id] = new AbortController();
485
513
486
514
let content_el = document.getElementById(`gpt_${message_id}`)
487
515
let content_map = content_storage[message_id] = {
516
container: message_el,
488
517
content: content_el,
489
518
inner: content_el.querySelector('.content_inner'),
490
519
count: content_el.querySelector('.count'),
491
520
}
492
493
await scroll_to_bottom();
521
if (message_index == -1) {
522
await scroll_to_bottom();
523
}
494
524
try {
495
525
const input = imageInput && imageInput.files.length > 0 ? imageInput : cameraInput;
496
526
const file = input && input.files.length > 0 ? input.files[0] : null;
@@ -527,14 +557,23 @@ const ask_gpt = async (message_index = -1, message_id) => {
527
557
delete controller_storage[message_id];
528
558
if (!error_storage[message_id] && message_storage[message_id]) {
529
559
const message_provider = message_id in provider_storage ? provider_storage[message_id] : null;
530
await add_message(window.conversation_id, "assistant", message_storage[message_id], message_provider);
531
await safe_load_conversation(window.conversation_id);
560
await add_message(
561
window.conversation_id,
562
"assistant",
563
message_storage[message_id],
564
message_provider,
565
message_index,
566
synthesize_storage[message_id]
567
);
568
await safe_load_conversation(window.conversation_id, message_index == -1);
532
569
} else {
533
let cursorDiv = message_box.querySelector(".cursor");
570
let cursorDiv = message_el.querySelector(".cursor");
534
571
if (cursorDiv) cursorDiv.parentNode.removeChild(cursorDiv);
535
572
}
536
await scroll_to_bottom();
537
await remove_cancel_button();
573
if (message_index == -1) {
574
await scroll_to_bottom();
575
}
576
await safe_remove_cancel_button();
538
577
await register_message_buttons();
539
578
await load_conversations();
540
579
regenerate.classList.remove("regenerate-hidden");
@@ -687,6 +726,15 @@ const load_conversation = async (conversation_id, scroll=true) => {
687
726
${item.provider.model ? ' with ' + item.provider.model : ''}
688
727
</div>
689
728
` : "";
729
let audio = "";
730
if (item.synthesize) {
731
const synthesize_params = (new URLSearchParams(item.synthesize.data)).toString();
732
audio = `
733
<audio controls preload="none">
734
<source src="/backend-api/v2/synthesize/${item.synthesize.provider}?${synthesize_params}" type="audio/mpeg">
735
</audio>
736
`;
737
}
690
738
elements += `
691
739
<div class="message${item.regenerate ? " regenerate": ""}" data-index="${i}">
692
740
<div class="${item.role}">
@@ -700,12 +748,14 @@ const load_conversation = async (conversation_id, scroll=true) => {
700
748
<div class="content">
701
749
${provider}
702
750
<div class="content_inner">${markdown_render(item.content)}</div>
751
${audio}
703
752
<div class="count">
704
753
${count_words_and_tokens(item.content, next_provider?.model)}
705
754
<i class="fa-solid fa-volume-high"></i>
706
755
<i class="fa-regular fa-clipboard"></i>
707
756
<a><i class="fa-brands fa-whatsapp"></i></a>
708
757
<i class="fa-solid fa-print"></i>
758
<i class="fa-solid fa-rotate"></i>
709
759
</div>
710
760
</div>
711
761
</div>
@@ -830,14 +880,35 @@ const get_message = async (conversation_id, index) => {
830
880
return messages[index]["content"];
831
881
};
832
882
833
const add_message = async (conversation_id, role, content, provider) => {
883
const add_message = async (
884
conversation_id, role, content,
885
provider = null,
886
message_index = -1,
887
synthesize_data = null
888
) => {
834
889
const conversation = await get_conversation(conversation_id);
835
890
if (!conversation) return;
836
conversation.items.push({
891
const new_message = {
837
892
role: role,
838
893
content: content,
839
provider: provider
840
});
894
provider: provider,
895
};
896
if (synthesize_data) {
897
new_message.synthesize = synthesize_data;
898
}
899
if (message_index == -1) {
900
conversation.items.push(new_message);
901
} else {
902
const new_messages = [];
903
conversation.items.forEach((item, index)=>{
904
new_messages.push(item);
905
if (index == message_index) {
906
new_message.regenerate = true;
907
new_messages.push(new_message);
908
}
909
});
910
conversation.items = new_messages;
911
}
841
912
await save_conversation(conversation_id, conversation);
842
913
return conversation.items.length - 1;
843
914
};
@@ -13,7 +13,7 @@ from g4f.errors import VersionNotFoundError
13
13
from g4f.image import ImagePreview, ImageResponse, copy_images, ensure_images_dir, images_dir
14
14
from g4f.Provider import ProviderType, __providers__, __map__
15
15
from g4f.providers.base_provider import ProviderModelMixin
16
from g4f.providers.response import BaseConversation, FinishReason
16
from g4f.providers.response import BaseConversation, FinishReason, SynthesizeData
17
17
from g4f.client.service import convert_to_provider
18
18
from g4f import debug
19
19
@@ -177,6 +177,8 @@ class Api:
177
177
images = asyncio.run(copy_images(chunk.get_list(), chunk.options.get("cookies")))
178
178
images = ImageResponse(images, chunk.alt)
179
179
yield self._format_json("content", str(images))
180
elif isinstance(chunk, SynthesizeData):
181
yield self._format_json("synthesize", chunk.to_json())
180
182
elif not isinstance(chunk, FinishReason):
181
183
yield self._format_json("content", str(chunk))
182
184
if debug.logs:
@@ -1,8 +1,36 @@
1
1
import json
2
import asyncio
3
import flask
2
4
from flask import request, Flask
5
from typing import AsyncGenerator, Generator
6
3
7
from g4f.image import is_allowed_extension, to_image
8
from g4f.client.service import convert_to_provider
9
from g4f.errors import ProviderNotFoundError
4
10
from .api import Api
5
11
12
def safe_iter_generator(generator: Generator) -> Generator:
13
start = next(generator)
14
def iter_generator():
15
yield start
16
yield from generator
17
return iter_generator()
18
19
def to_sync_generator(gen: AsyncGenerator) -> Generator:
20
loop = asyncio.new_event_loop()
21
asyncio.set_event_loop(loop)
22
gen = gen.__aiter__()
23
async def get_next():
24
try:
25
obj = await gen.__anext__()
26
return False, obj
27
except StopAsyncIteration: return True, None
28
while True:
29
done, obj = loop.run_until_complete(get_next())
30
if done:
31
break
32
yield obj
33
6
34
class Backend_Api(Api):
7
35
"""
8
36
Handles various endpoints in a Flask application for backend operations.
@@ -47,6 +75,10 @@ class Backend_Api(Api):
47
75
'function': self.handle_conversation,
48
76
'methods': ['POST']
49
77
},
78
'/backend-api/v2/synthesize/<provider>': {
79
'function': self.handle_synthesize,
80
'methods': ['GET']
81
},
50
82
'/backend-api/v2/error': {
51
83
'function': self.handle_error,
52
84
'methods': ['POST']
@@ -98,11 +130,28 @@ class Backend_Api(Api):
98
130
mimetype='text/event-stream'
99
131
)
100
132
133
def handle_synthesize(self, provider: str):
134
try:
135
provider_handler = convert_to_provider(provider)
136
except ProviderNotFoundError:
137
return "Provider not found", 404
138
if not hasattr(provider_handler, "synthesize"):
139
return "Provider doesn't support synthesize", 500
140
try:
141
response_generator = provider_handler.synthesize({**request.args})
142
if hasattr(response_generator, "__aiter__"):
143
response_generator = to_sync_generator(response_generator)
144
response = flask.Response(safe_iter_generator(response_generator), content_type="audio/mpeg")
145
response.headers['Cache-Control'] = "max-age=604800"
146
return response
147
except Exception as e:
148
return f"{e.__class__.__name__}: {e}", 500
149
101
150
def get_provider_models(self, provider: str):
102
151
api_key = None if request.authorization is None else request.authorization.token
103
152
models = super().get_provider_models(provider, api_key)
104
153
if models is None:
105
return 404, "Provider not found"
154
return "Provider not found", 404
106
155
return models
107
156
108
157
def _format_json(self, response_type: str, content) -> str:
@@ -11,7 +11,7 @@ from typing import Callable, Union
11
11
12
12
from ..typing import CreateResult, AsyncResult, Messages
13
13
from .types import BaseProvider
14
from .response import FinishReason, BaseConversation
14
from .response import FinishReason, BaseConversation, SynthesizeData
15
15
from ..errors import NestAsyncioError, ModelNotSupportedError
16
16
from .. import debug
17
17
@@ -259,7 +259,7 @@ class AsyncGeneratorProvider(AsyncProvider):
259
259
"""
260
260
return "".join([
261
261
str(chunk) async for chunk in cls.create_async_generator(model, messages, stream=False, **kwargs)
262
if not isinstance(chunk, (Exception, FinishReason, BaseConversation))
262
if not isinstance(chunk, (Exception, FinishReason, BaseConversation, SynthesizeData))
263
263
])
264
264
265
265
@staticmethod
@@ -22,5 +22,18 @@ class Sources(ResponseType):
22
22
return "\n\n" + ("\n".join([f"{idx+1}. [{link['title']}]({link['url']})" for idx, link in enumerate(self.list)]))
23
23
24
24
class BaseConversation(ResponseType):
25
def __str__(self) -> str:
26
return ""
27
28
class SynthesizeData(ResponseType):
29
def __init__(self, provider: str, data: dict):
30
self.provider = provider
31
self.data = data
32
33
def to_json(self) -> dict:
34
return {
35
**self.__dict__
36
}
37
25
38
def __str__(self) -> str:
26
39
return ""