XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
返回提交历史

XFEstudio/gpt4free

Add provider improvments

0c72e0da
hlohaus <983577+hlohaus@users.noreply.github.com>
提交于

代码差异

10 个文件 +356 -137
Modified etc/unittest/test_cdp_parallel.py +1 -59
@@ -1,7 +1,7 @@
1 1 """
2 2 Tests for parallel CDP tab support.
3 3
4 Verifies that multiple CDPSession / SyncCDPSession instances can share a
4 Verifies that multiple CDPSession instances can share a
5 5 single browser process concurrently without killing each other when one
6 6 tab closes.
7 7 """
@@ -14,7 +14,6 @@ import unittest
14 14
15 15 from g4f.requests.cdp import (
16 16 CDPSession,
17 SyncCDPSession,
18 17 acquire_shared_browser_ref,
19 18 release_shared_browser_ref,
20 19 get_shared_browser,
@@ -206,62 +205,5 @@ class TestCDPSessionParallel(unittest.TestCase):
206 205
207 206 asyncio.run(run())
208 207
209
210 @unittest.skipUnless(
211 (__import__("shutil").which("google-chrome")
212 or __import__("shutil").which("chromium")
213 or __import__("shutil").which("chromium-browser")
214 or __import__("os").path.exists("/usr/bin/google-chrome")
215 or __import__("os").path.exists("/usr/bin/chromium-browser"))
216 and __import__("importlib").util.find_spec("websocket"),
217 "No Chrome/Chromium executable or websocket-client not installed",
218 )
219 class TestSyncCDPSessionParallel(unittest.TestCase):
220 """Integration tests for SyncCDPSession parallel tab support."""
221
222 def setUp(self):
223 _force_terminate_shared_browser()
224
225 def tearDown(self):
226 _force_terminate_shared_browser()
227
228 def test_two_sync_sessions_same_browser(self):
229 """Two SyncCDPSession instances should share the same browser port."""
230 s1 = SyncCDPSession(headless=True)
231 s2 = SyncCDPSession(headless=True)
232 s1.start_chrome()
233 s2.start_chrome()
234 try:
235 self.assertEqual(s1.port, s2.port)
236 self.assertNotEqual(s1.target_id, s2.target_id)
237 s1.navigate("about:blank")
238 s2.navigate("about:blank")
239 self.assertTrue(_browser_alive(s1.host, s1.port))
240 finally:
241 s2.close()
242 # Browser should still be alive
243 self.assertTrue(_browser_alive(s1.host, s1.port))
244 s1.close()
245 # Browser stays alive (idle timer keeps it for reuse)
246 self.assertTrue(_browser_alive("127.0.0.1", s1.port))
247
248 def test_sync_close_one_keeps_browser(self):
249 """Closing one SyncCDPSession must not kill the browser while another is active."""
250 s1 = SyncCDPSession(headless=True)
251 s2 = SyncCDPSession(headless=True)
252 s1.start_chrome()
253 s2.start_chrome()
254 port = s1.port
255 host = s1.host
256 try:
257 s2.close()
258 self.assertTrue(_browser_alive(host, port))
259 s1.navigate("about:blank")
260 self.assertTrue(_browser_alive(host, port))
261 finally:
262 s1.close()
263 self.assertTrue(_browser_alive(host, port))
264
265
266 208 if __name__ == "__main__":
267 209 unittest.main()
Modified g4f/Provider/DeepInfra.py +15 -1
@@ -54,6 +54,9 @@ async def _get_turnstile_token_async(model: str) -> str:
54 54 debug.log("[DeepInfra] Waiting for active textarea...")
55 55 text_entered = False
56 56 for _ in range(80): # Up to 40 seconds
57 if not session.is_alive:
58 debug.log("[DeepInfra] Browser session lost, aborting textarea wait.")
59 break
57 60 try:
58 61 ready = await session.evaluate_js(
59 62 """
@@ -126,6 +129,10 @@ async def _get_turnstile_token_async(model: str) -> str:
126 129
127 130 text_entered = True
128 131 break
132 except (ConnectionError, RuntimeError) as e:
133 if not session.is_alive:
134 debug.log(f"[DeepInfra] Browser session lost during textarea wait: {e}")
135 break
129 136 except Exception:
130 137 pass
131 138 await asyncio.sleep(0.5)
@@ -142,11 +149,18 @@ async def _get_turnstile_token_async(model: str) -> str:
142 149 token_js = "document.querySelector('[name=cf-turnstile-response]') ? document.querySelector('[name=cf-turnstile-response]').value : ''"
143 150 token = ""
144 151 for i in range(240): # Up to 120 seconds per attempt
152 if not session.is_alive:
153 debug.log("[DeepInfra] Browser session lost, aborting Turnstile token poll.")
154 break
145 155 try:
146 156 token = await session.evaluate_js(token_js)
147 157 if token:
148 158 debug.log(f"[DeepInfra] Token generated on check {i+1}!")
149 159 return token
160 except (ConnectionError, RuntimeError) as e:
161 if not session.is_alive:
162 debug.log(f"[DeepInfra] Browser session lost during token poll: {e}")
163 break
150 164 except Exception:
151 165 pass
152 166 await asyncio.sleep(0.5)
@@ -171,7 +185,7 @@ class DeepInfra(OpenaiTemplate):
171 185 login_url = "https://deepinfra.com/dash/api_keys"
172 186 base_url = "https://api.deepinfra.com/v1/openai"
173 187
174 working = True
188 working = False
175 189 active_by_default = True
176 190
177 191 default_model = "zai-org/GLM-5.2"
Modified g4f/Provider/needs_auth/BingCreateImages.py +1 -0
@@ -12,6 +12,7 @@ from ..helper import format_media_prompt
12 12 class BingCreateImages(AsyncGeneratorProvider, ProviderModelMixin):
13 13 label = "Microsoft Designer in Bing"
14 14 url = "https://www.bing.com/images/create"
15 screenshot_url = "https://www.bing.com"
15 16 working = True
16 17 needs_auth = True
17 18 image_models = ["dall-e-3"]
Modified g4f/Provider/needs_auth/Cerebras.py +1 -1
@@ -10,7 +10,7 @@ from ...cookies import get_cookies, get_cookies_async
10 10
11 11 class Cerebras(OpenaiAPI):
12 12 label = "Cerebras Inference"
13 url = "https://chat.cerebras.ai/"
13 url = "https://chat.cerebras.ai"
14 14 login_url = "https://cloud.cerebras.ai"
15 15 base_url = "https://api.cerebras.ai/v1"
16 16 working = True
Modified g4f/Provider/needs_auth/OpenaiChat.py +208 -27
@@ -3,6 +3,7 @@ from __future__ import annotations
3 3 import asyncio
4 4 import base64
5 5 import hashlib
6 import html
6 7 import json
7 8 import os
8 9 import random
@@ -91,6 +92,21 @@ _RE_IMAGES = re.compile(r"^/message/metadata/content_references/(\d+)/images$")
91 92 _RE_ACCESS_TOKEN = re.compile(r'"accessToken":"(.+?)"')
92 93 _RE_UTM_SOURCE = re.compile(r"[&?]utm_source=.+")
93 94
95 # New anonymous/guest chat surface ("web-mobile") — used when no access token
96 # is available; the classic backend-anon/f/conversation API no longer serves
97 # unauthenticated requests.
98 mweb_chat_requirements_prepare_url = "https://chatgpt.com/unauth-mweb/sentinel/chat-requirements/prepare"
99 mweb_chat_requirements_finalize_url = "https://chatgpt.com/unauth-mweb/sentinel/chat-requirements/finalize"
100 mweb_conversation_prepare_url = "https://chatgpt.com/unauth-mweb/conversation/prepare"
101 mweb_conversation_updates_url = "https://chatgpt.com/unauth-mweb/conversation/updates"
102 _RE_MWEB_CONVERSATION_ID = re.compile(r'data-conversation-id="([\w-]+)"')
103 _RE_MWEB_MESSAGE_ID = re.compile(r'data-message-id="([\w-]+)"')
104 _RE_MWEB_ASSISTANT_BLOCK = re.compile(
105 r'<p data-assistant-stream-block="" data-assistant-stream-block-index="(\d+)">(.*?)</p>',
106 re.DOTALL,
107 )
108 _RE_MWEB_MARKER = re.compile(r'<\?[^>]*>')
109
94 110 DEFAULT_HEADERS = {
95 111 "accept": "*/*",
96 112 "accept-encoding": "gzip, deflate, br, zstd",
@@ -167,6 +183,7 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
167 183 model_aliases = model_aliases
168 184 synthesize_content_type = "audio/aac"
169 185 request_config = RequestConfig()
186 supports_native_tools = True
170 187 quota_url = "https://chatgpt.com/backend-api/me"
171 188
172 189 _api_key: str = None
@@ -467,6 +484,108 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
467 484 {"status": status, "headers": auth_result.headers},
468 485 )
469 486
487 @classmethod
488 async def create_anonymous_mweb(
489 cls,
490 session: StreamSession,
491 auth_result: AuthResult,
492 prompt: str,
493 conversation: Conversation,
494 ) -> str:
495 """Send a single guest message via ChatGPT's "web-mobile" surface.
496
497 Replaces the retired ``backend-anon/f/conversation`` JSON API, which
498 no longer serves unauthenticated requests. This surface returns the
499 full (non-streamed) reply as an HTML partial-update document.
500 """
501 user_agent = getattr(auth_result, "headers", {}).get("user-agent")
502 proof_token = getattr(auth_result, "proof_token", None)
503 if proof_token is None:
504 proof_token = auth_result.proof_token = get_config(user_agent)
505 json_headers = {**cls._headers, "accept": "application/json", "content-type": "application/json"}
506 async with session.post(
507 mweb_chat_requirements_prepare_url,
508 json={"p": get_requirements_token(proof_token)},
509 headers=json_headers,
510 ) as response:
511 await raise_for_status(response)
512 prepare_token = (await response.json())["prepare_token"]
513 async with session.post(
514 mweb_chat_requirements_finalize_url,
515 json={"prepare_token": prepare_token},
516 headers=json_headers,
517 ) as response:
518 await raise_for_status(response)
519 chat_requirements_token = (await response.json())["token"]
520 session_id = str(uuid.uuid4())
521 operation_id = str(uuid.uuid4())
522 conversation_state = {
523 "messages": [],
524 "parentMessageId": conversation.parent_message_id or "client-created-root",
525 "userMessageCount": 0,
526 }
527 form_headers = {
528 **cls._headers,
529 "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
530 "oai-session-id": session_id,
531 }
532 # Registers a "document worker" for this session — without this the
533 # updates call below responds with conversation-document-upgrade-required.
534 async with session.post(
535 f"{mweb_conversation_prepare_url}?lightweight_authenticated=0",
536 data={
537 "conversationRetryOwner": json.dumps({"mode": "anonymous", "sessionEpoch": None}),
538 "conversationState": json.dumps(conversation_state),
539 "clientContextualInfo": json.dumps({
540 "app_name": "chatgpt.com",
541 "has_web_push_capabilities": True,
542 "is_dark_mode": False,
543 "web_push_notification_permission": "default",
544 "page_height": 800,
545 "page_width": 1280,
546 "pixel_ratio": 1,
547 "screen_height": 1080,
548 "screen_width": 1920,
549 "time_since_loaded": random.randint(2, 10),
550 }),
551 "timezone": "Europe/Berlin",
552 "timezoneOffsetMinutes": -120,
553 },
554 headers={**form_headers, "accept": "*/*"},
555 ) as response:
556 await raise_for_status(response)
557 form_data = {
558 "conversationState": json.dumps(conversation_state),
559 "messageMetadata": "{}",
560 "oai-session-id": session_id,
561 "imageAttachments": "[]",
562 "pendingImageUploads": "[]",
563 "prompt": prompt,
564 "chatRequirementsToken": chat_requirements_token,
565 }
566 async with session.post(
567 f"{mweb_conversation_updates_url}?lightweight_authenticated=0&operationId={operation_id}",
568 data=form_data,
569 headers={**form_headers, "accept": "text/vnd.openai.web-mobile-partial+html"},
570 ) as response:
571 await raise_for_status(response)
572 text = await response.text()
573 conversation_id_match = _RE_MWEB_CONVERSATION_ID.search(text)
574 if conversation_id_match:
575 conversation.conversation_id = conversation_id_match.group(1)
576 message_id_match = _RE_MWEB_MESSAGE_ID.search(text)
577 if message_id_match:
578 conversation.parent_message_id = conversation.message_id = message_id_match.group(1)
579 conversation.finish_reason = "stop"
580 # Later blocks with the same index are streaming updates that
581 # supersede earlier (partial) ones — keep only the last per index.
582 blocks = {}
583 for index, block in _RE_MWEB_ASSISTANT_BLOCK.findall(text):
584 blocks[int(index)] = _RE_MWEB_MARKER.sub("", block)
585 if not blocks:
586 debug.log(f"OpenaiChat: MWEB response had no assistant block: {text[:500]!r}")
587 return html.unescape("".join(blocks[index] for index in sorted(blocks)))
588
470 589 @classmethod
471 590 async def create_authed(
472 591 cls,
@@ -518,6 +637,20 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
518 637 ) as session:
519 638 image_requests = None
520 639 media = merge_media(media, messages)
640 # A previously captured token can outlive its own expiry across
641 # calls (cls._api_key is a class-level attribute) — drop it here
642 # so a stale/expired token doesn't get treated as authenticated.
643 if (
644 cls._api_key is not None
645 and cls._expires is not None
646 and time.time() > cls._expires
647 ):
648 cls._api_key = None
649 if cls._api_key is None and media:
650 # Anonymous chat doesn't support image uploads yet (the
651 # retired backend-anon endpoints used for that no longer work).
652 debug.log("OpenaiChat: Dropping media for anonymous chat (not supported)")
653 media = []
521 654 if not cls.needs_auth and not media:
522 655 if cls._headers is None:
523 656 cls._create_request_args(cls._cookies)
@@ -572,6 +705,20 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
572 705 if cls._api_key is None:
573 706 auto_continue = False
574 707 conversation.finish_reason = None
708 if cls._api_key is None:
709 # Guest chat now goes through the "web-mobile" surface —
710 # the legacy backend-anon JSON API no longer accepts requests.
711 prompt = conversation.prompt = format_media_prompt(messages, prompt)
712 print(f"OpenaiChat: Guest prompt: {prompt}")
713 reply = await cls.create_anonymous_mweb(session, auth_result, prompt, conversation)
714 print(f"OpenaiChat: Guest reply: {reply}")
715 if reply:
716 yield reply
717 conversation.prompt = None
718 if return_conversation:
719 yield conversation
720 yield FinishReason(conversation.finish_reason)
721 return
575 722 sources = OpenAISources([])
576 723 references = ContentReferences()
577 724 system_hints = ["picture_v2"] if image_model else []
@@ -727,10 +874,10 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
727 874 headers=headers,
728 875 ) as response:
729 876 cls._update_request_args(auth_result, session)
730 if response.status in (401, 403, 429, 500):
731 raise MissingAuthError("Access token is not valid")
732 elif response.status == 422:
733 raise RuntimeError((await response.json()), data)
877 # if response.status in (401, 403, 429, 500):
878 # raise MissingAuthError("Access token is not valid")
879 # elif response.status == 422:
880 # raise RuntimeError((await response.json()), data)
734 881 await raise_for_status(response)
735 882 buffer = ""
736 883 matches = []
@@ -1353,19 +1500,25 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1353 1500 f"Access token is not valid: {cls.request_config.access_token}"
1354 1501 )
1355 1502 except NoValidHarFileError:
1356 if cls.request_config.access_token is None:
1357 yield RequestLogin(
1358 cls.label, os.environ.get("G4F_LOGIN_URL", "")
1359 )
1360 await cls.nodriver_auth(proxy)
1361 else:
1362 raise
1503 # An expired cached token needs the same browser re-login as a
1504 # missing one — re-raising here would surface a stale
1505 # MissingAuthError instead of actually refreshing the token.
1506 yield RequestLogin(
1507 cls.label, os.environ.get("G4F_LOGIN_URL", "")
1508 )
1509 await cls.nodriver_auth(proxy)
1363 1510
1364 1511 @classmethod
1365 1512 async def nodriver_auth(cls, proxy: str = None):
1366 1513 async with get_nodriver_session(proxy=proxy) as browser:
1367 1514 page = await browser.get(cls.url)
1515 try:
1516 await cls._nodriver_auth_page(page)
1517 finally:
1518 await page.close()
1368 1519
1520 @classmethod
1521 async def _nodriver_auth_page(cls, page):
1369 1522 def on_request(event, page=None):
1370 1523 if not hasattr(event, "request"):
1371 1524 return
@@ -1410,31 +1563,58 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1410 1563 "window.navigator.userAgent", return_by_value=True
1411 1564 )
1412 1565 debug.log(f"OpenaiChat: User-Agent: {user_agent}")
1413 for _ in range(3):
1566 logged_in = not cls.needs_auth
1567 for attempt in range(3):
1414 1568 try:
1415 1569 if cls.needs_auth:
1416 try:
1417 await page.select(
1418 '[data-testid="accounts-profile-button"]', 300
1570 debug.log(
1571 f"OpenaiChat: Waiting for login (attempt {attempt + 1}/3, up to 300s)..."
1572 )
1573 profile_button = await page.select(
1574 '[data-testid="accounts-profile-button"]', 300
1575 )
1576 if profile_button is None:
1577 title = await page.evaluate("document.title", return_by_value=True)
1578 url = await page.evaluate("window.location.href", return_by_value=True)
1579 debug.log(
1580 f"OpenaiChat: Not logged in yet (title={title!r}, url={url!r})"
1419 1581 )
1420 except TimeoutError:
1421 1582 continue
1422 try:
1423 textarea = await page.select("#prompt-textarea", 300)
1424 await textarea.send_keys("Hello")
1425 await asyncio.sleep(1)
1426 except TimeoutError:
1583 logged_in = True
1584 debug.log(
1585 f"OpenaiChat: Waiting for #prompt-textarea (attempt {attempt + 1}/3, up to 300s)..."
1586 )
1587 textarea = await page.select("#prompt-textarea, #mobile-composer-prompt", 300)
1588 if textarea is None:
1589 title = await page.evaluate("document.title", return_by_value=True)
1590 url = await page.evaluate("window.location.href", return_by_value=True)
1591 debug.log(
1592 f"OpenaiChat: #prompt-textarea not found (title={title!r}, url={url!r})"
1593 )
1427 1594 continue
1595 await textarea.send_keys("Hello")
1596 await asyncio.sleep(1)
1428 1597 except cdp.runtime.ProtocolException:
1429 1598 continue
1430 1599 break
1431 try:
1432 button = await page.select('[data-testid="send-button"]')
1600 if not logged_in:
1601 # Without a confirmed login the page falls back to ChatGPT's
1602 # anonymous "unauth-mweb" guest UI, which never yields a real
1603 # access token — fail clearly instead of hanging or silently
1604 # continuing as a guest.
1605 raise MissingAuthError(
1606 "Login was not completed in the browser window in time"
1607 )
1608 # Mobile layout uses [data-composer-submit] instead of data-testid.
1609 button = await page.select(
1610 '[data-testid="send-button"], [data-composer-submit]'
1611 )
1612 if button is not None:
1433 1613 await button.click()
1434 1614 debug.log("OpenaiChat: 'Hello' sended")
1435 except TimeoutError:
1436 pass
1437 while True:
1615 else:
1616 debug.log("OpenaiChat: send-button not found, 'Hello' not sent")
1617 for _ in range(120):
1438 1618 body = await page.evaluate(
1439 1619 "JSON.stringify(window.__remixContext)", return_by_value=True
1440 1620 )
@@ -1452,6 +1632,8 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1452 1632 debug.log(
1453 1633 f"OpenaiChat: Access token: {'False' if cls._api_key is None else cls._api_key[:12] + '...'}"
1454 1634 )
1635 if cls.needs_auth and cls._api_key is None:
1636 raise MissingAuthError("Could not obtain an access token after login")
1455 1637 # while True:
1456 1638 # if cls.request_config.proof_token:
1457 1639 # break
@@ -1462,7 +1644,6 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
1462 1644 "document.documentElement.getAttribute('data-build')"
1463 1645 )
1464 1646 cls.request_config.cookies = await page.send(get_cookies([cls.url]))
1465 await page.close()
1466 1647 cls._create_request_args(
1467 1648 cls.request_config.cookies,
1468 1649 cls.request_config.headers,
Modified g4f/api/__init__.py +5 -2
@@ -16,7 +16,7 @@ import os.path
16 16 import hashlib
17 17 import base64
18 18 from contextlib import asynccontextmanager
19 from urllib.parse import quote_plus
19 from urllib.parse import quote_plus, unquote_plus
20 20 from fastapi import FastAPI, Response, Request, UploadFile, Form, Depends, Header
21 21 from fastapi.responses import (
22 22 StreamingResponse,
@@ -158,7 +158,7 @@ section{padding:28px 0 92px}.section-head{display:flex;justify-content:space-bet
158 158 <a class="card" href="/docs"><h3>Live schema</h3><p>Try requests in your browser and inspect the generated OpenAPI contract.</p><span class="endpoint">GET /docs</span></a>
159 159 </div></section><section><div class="section-head"><h2>Start here</h2><span>curl · JSON · streamable</span></div><div class="code-box"><button class="copy" onclick="copyExample(this)">copy</button><pre id="example">curl -X POST /v1/chat/completions \\
160 160 -H 'Content-Type: application/json' \\
161 -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Hello"}]}'</pre></div></section></main>
161 -d '{"model":"auto","messages":[{"role":"user","content":"Hello"}]}'</pre></div></section></main>
162 162 <footer>g4f API · <a href="/openapi.json">openapi.json</a> · Compatible clients welcome.</footer></div>
163 163 <script>function copyExample(button){navigator.clipboard?.writeText(document.getElementById("example").textContent).then(()=>{button.textContent="copied";setTimeout(()=>button.textContent="copy",1400)})}</script></body></html>"""
164 164
@@ -2473,6 +2473,9 @@ def run_api(
2473 2473 uvicorn_options = {
2474 2474 "timeout_keep_alive": 65,
2475 2475 "backlog": 2048,
2476 # Avoid hanging forever on shutdown when a long-running request (e.g.
2477 # a browser-based provider login) is still in-flight.
2478 "timeout_graceful_shutdown": 10,
2476 2479 }
2477 2480 uvicorn_options.update(filter_none(**kwargs))
2478 2481
Modified g4f/gui/server/website.py +1 -1
@@ -370,7 +370,7 @@ class Website:
370 370 # Screenshot / logo section
371 371 screenshot_url = p.get("screenshot_url") or p.get("url") or ""
372 372 create_url = f"/screenshot?url={quote_plus(str(screenshot_url))}"
373 screenshot_url = screenshot_url.replace("https://", "").replace("http://", "")
373 screenshot_url = screenshot_url.replace("https://", "").replace("http://", "").replace("www.", "")
374 374 logo_url = "https://g4f.space/logo/" + p.get("name", "").replace(
375 375 'MetaAIAccount', 'Facebook AI').replace(
376 376 'MetaAI', 'Facebook AI').replace(
Modified g4f/requests/__init__.py +16 -5
@@ -9,7 +9,7 @@ from collections.abc import Callable
9 9 from contextlib import asynccontextmanager
10 10 from http.cookies import Morsel
11 11 from pathlib import Path
12 from typing import Iterator, AsyncIterator
12 from typing import Iterator, AsyncIterator, Optional
13 13 from urllib.parse import urlparse
14 14
15 15 try:
@@ -50,6 +50,8 @@ from .cdp_browser import (
50 50 get_cookie_params_from_dict as _get_cookie_params_from_dict_cdp,
51 51 )
52 52
53 Browser = CDPBrowser
54
53 55 from .. import debug
54 56 from .raise_for_status import raise_for_status
55 57 from ..errors import MissingRequirementsError
@@ -280,13 +282,14 @@ def _make_cdp_on_stop(user_data_dir: str):
280 282 return on_stop
281 283
282 284 def set_browser_executable_path(browser_executable_path: str):
283 BrowserConfig.browser_executable_path = browser_executable_path
285 BrowserConfig.executable_path = browser_executable_path
284 286
285 287 async def get_nodriver(
286 288 proxy: str = None,
287 289 user_data_dir="nodriver",
288 290 timeout: int = 300,
289 291 browser_executable_path: str = None,
292 browser_args: list = None,
290 293 **kwargs,
291 294 ) -> tuple:
292 295 """Return a CDPBrowser wrapper that emulates the nodriver Browser API.
@@ -300,6 +303,9 @@ async def get_nodriver(
300 303 'Chrome/Chromium/Edge executable not found. Install Google Chrome.'
301 304 )
302 305
306 if browser_executable_path:
307 set_browser_executable_path(browser_executable_path)
308
303 309 ud_key = str(user_data_dir) if user_data_dir else "default"
304 310
305 311 async with _shared_cdp_lock:
@@ -312,7 +318,10 @@ async def get_nodriver(
312 318
313 319 # No shared browser yet — create a new CDPBrowser
314 320 headless = BrowserConfig.headless if BrowserConfig.headless is not None else True
315 browser = CDPBrowser(headless=headless, proxy=proxy, user_data_dir=user_data_dir)
321 browser = CDPBrowser(
322 headless=headless, proxy=proxy, user_data_dir=user_data_dir,
323 browser_args=browser_args,
324 )
316 325
317 326 async with _shared_cdp_lock:
318 327 _shared_cdp_browsers[ud_key] = (browser, 1)
@@ -325,8 +334,10 @@ async def get_nodriver(
325 334 @asynccontextmanager
326 335 async def get_nodriver_session(**kwargs):
327 336 browser, stop_browser = await get_nodriver(**kwargs)
328 yield browser
329 await stop_browser()
337 try:
338 yield browser
339 finally:
340 await stop_browser()
330 341
331 342
332 343
Modified g4f/requests/cdp.py +69 -24
@@ -19,27 +19,6 @@ CDPSession (Async) — for high-throughput providers like Cloudflare.
19 19 finally:
20 20 await session.close()
21 21
22 ──────────────────────────────────────────────────────────────────────
23 SyncCDPSession (Sync) — for Turnstile-solving providers like DeepInfra.
24 ──────────────────────────────────────────────────────────────────────
25 • Synchronous blocking recv() loop — waits as long as the browser needs.
26 • No async timeouts — more reliable for slow/interactive pages.
27 • Run from an async context via run_in_executor().
28 • Requires: pip install websocket-client
29
30 Example:
31 def run_sync():
32 session = SyncCDPSession(port=12345, headless=False)
33 session.start_chrome()
34 try:
35 session.navigate("https://example.com")
36 title = session.evaluate_js("document.title")
37 return title
38 finally:
39 session.close()
40
41 title = await asyncio.get_event_loop().run_in_executor(None, run_sync)
42
43 22 ──────────────────────────────────────────────────────────────────────
44 23 Common features:
45 24 • Auto-detects Chrome/Chromium/Edge path via BrowserConfig or system PATH.
@@ -263,10 +242,20 @@ def find_running_cdp_port(host: str) -> Optional[int]:
263 242 return None
264 243
265 244
266 def get_shared_browser(host: str, preferred_port: int, headless: bool = True) -> int:
245 def get_shared_browser(
246 host: str,
247 preferred_port: int,
248 headless: bool = True,
249 proxy: Optional[str] = None,
250 browser_args: Optional[List[str]] = None,
251 ) -> int:
267 252 """
268 253 Ensure a single shared browser instance is running and return its port.
269 254 If a browser is already running anywhere on the system, we use it directly.
255
256 ``proxy``/``browser_args`` only take effect when the shared browser is
257 first launched — later callers reusing the shared process are ignored,
258 since Chrome does not support changing its proxy at runtime.
270 259 """
271 260 global _shared_browser_process, _shared_browser_port
272 261
@@ -359,6 +348,10 @@ def get_shared_browser(host: str, preferred_port: int, headless: bool = True) ->
359 348 ]
360 349 if headless:
361 350 cmd.append("--headless=new")
351 if proxy:
352 cmd.append(f"--proxy-server={proxy}")
353 if browser_args:
354 cmd.extend(browser_args)
362 355
363 356 debug.log(f"CDP: Launching Chrome: {' '.join(cmd)}")
364 357 _shared_browser_process = subprocess.Popen(
@@ -416,6 +409,8 @@ class CDPSession:
416 409 host: Optional[str] = None,
417 410 user_data_dir: Optional[str] = None,
418 411 headless: Optional[bool] = None,
412 proxy: Optional[str] = None,
413 browser_args: Optional[List[str]] = None,
419 414 ):
420 415 if port is None:
421 416 port = BrowserConfig.port
@@ -428,6 +423,8 @@ class CDPSession:
428 423 if headless is None:
429 424 headless = BrowserConfig.headless
430 425 self.headless = headless
426 self.proxy = proxy
427 self.browser_args = browser_args
431 428 self.user_data_dir = (
432 429 user_data_dir # Ignored if using shared pool, but kept for compatibility
433 430 )
@@ -441,15 +438,23 @@ class CDPSession:
441 438 self._event_handlers: Dict[str, List[asyncio.Future]] = {}
442 439 self._event_queues: Dict[str, List[asyncio.Queue]] = {}
443 440 self._closing = False
441 self._connection_lost = False
444 442
445 443 # Network event loggers
446 444 self.network_requests: List[dict] = []
447 445 self.network_responses: List[dict] = []
448 446
447 @property
448 def is_alive(self) -> bool:
449 """Return True if the WebSocket is still connected and not closing."""
450 return not self._closing and not self._connection_lost and self.ws is not None and not self.ws.closed
451
449 452 async def start(self):
450 453 """Launch/get shared Chrome and connect via CDP targeting a new tab."""
451 454 if self.port is None:
452 self.port = get_shared_browser(self.host, self.port, self.headless)
455 self.port = get_shared_browser(
456 self.host, self.port, self.headless, self.proxy, self.browser_args
457 )
453 458
454 459 # Acquire a reference so the shared browser stays alive for this tab
455 460 acquire_shared_browser_ref()
@@ -492,6 +497,20 @@ class CDPSession:
492 497 await self.call("Network.enable")
493 498 await self.call("Emulation.setFocusEmulationEnabled", enabled=True)
494 499
500 # Force a desktop-sized viewport — the OS window size hint
501 # (--window-size) is not always honored by the window manager, which
502 # can leave the page narrow enough to trigger a site's mobile layout.
503 try:
504 await self.call(
505 "Emulation.setDeviceMetricsOverride",
506 width=1280,
507 height=800,
508 deviceScaleFactor=1,
509 mobile=False,
510 )
511 except Exception:
512 pass
513
495 514 # Anti-detect: Override User-Agent to remove "HeadlessChrome"
496 515 user_agent = await self.evaluate_js("navigator.userAgent")
497 516 if user_agent and "HeadlessChrome" in user_agent:
@@ -552,11 +571,15 @@ class CDPSession:
552 571 except Exception as e:
553 572 if not self._closing:
554 573 logger.error(f"CDP receiver loop error: {e}")
574 finally:
575 self._connection_lost = True
555 576
556 577 async def call(self, method: str, **params) -> dict:
557 578 """Call a CDP method and wait for its result."""
558 579 if not self.ws:
559 580 raise RuntimeError("CDPSession is not connected")
581 if self._connection_lost or self.ws.closed:
582 raise ConnectionError("CDPSession connection lost (browser closed?)")
560 583
561 584 self.id_counter += 1
562 585 req_id = self.id_counter
@@ -565,7 +588,12 @@ class CDPSession:
565 588 self._pending_requests[req_id] = fut
566 589
567 590 payload = {"id": req_id, "method": method, "params": params}
568 await self.ws.send_json(payload)
591 try:
592 await self.ws.send_json(payload)
593 except Exception as e:
594 self._connection_lost = True
595 self._pending_requests.pop(req_id, None)
596 raise ConnectionError(f"CDPSession connection lost during send: {e}")
569 597
570 598 try:
571 599 return await asyncio.wait_for(fut, timeout=30.0)
@@ -658,6 +686,22 @@ class CDPSession:
658 686 f"Timeout waiting for Page.loadEventFired when navigating to {url}"
659 687 )
660 688
689 async def reload(self):
690 """Reload the current page and wait for it to load."""
691 fut = asyncio.get_running_loop().create_future()
692 if "Page.loadEventFired" not in self._event_handlers:
693 self._event_handlers["Page.loadEventFired"] = []
694 self._event_handlers["Page.loadEventFired"].append(fut)
695
696 await self.call("Page.reload")
697
698 try:
699 await asyncio.wait_for(fut, timeout=30.0)
700 except asyncio.TimeoutError:
701 if fut in self._event_handlers.get("Page.loadEventFired", []):
702 self._event_handlers["Page.loadEventFired"].remove(fut)
703 logger.warning("Timeout waiting for Page.loadEventFired when reloading")
704
661 705 async def wait_for_network_idle(
662 706 self, idle_time: float = 0.5, timeout: float = 15.0
663 707 ) -> bool:
@@ -1005,6 +1049,7 @@ if (deepseekSendButton) {
1005 1049 """Navigate to a URL and capture a screenshot, caching the result."""
1006 1050 url_without_suffix = url[:-7] if url.endswith("_2.webp") or url.endswith("_3.webp") else url
1007 1051 url_with_noads = f"{url_without_suffix}&noads={int(time.time())}" if "?" in url_without_suffix else f"{url_without_suffix}?noads={int(time.time())}"
1052 debug.log(f"Navigating to URL: {url_with_noads}")
1008 1053 await self.navigate(url_with_noads)
1009 1054
1010 1055 if await self.evaluate_js('!document.doctype'):
Modified g4f/requests/cdp_browser.py +39 -17
@@ -229,12 +229,20 @@ class CDPElement:
229 229 self._node_id = node_id
230 230
231 231 async def click(self):
232 """Scroll into view and click the element via JS."""
233 await self._tab.evaluate_js(
234 f"(function(){{var el=document.querySelector('[data-cdp-oid=\"{self._object_id}\"]');"
235 f"if(!el)return;el.scrollIntoView({{block:'center'}});el.click();}})()"
236 )
237 # Fallback: use CDP DOM.requestNode + Input dispatch
232 """Scroll into view and click the element."""
233 try:
234 await self._tab._session.call(
235 "Runtime.callFunctionOn",
236 objectId=self._object_id,
237 functionDeclaration=(
238 "function(){this.scrollIntoView({block:'center'});"
239 "this.click();}"
240 ),
241 )
242 return
243 except Exception:
244 pass
245 # Fallback: dispatch a real mouse click at the element's on-screen position.
238 246 try:
239 247 res = await self._tab._session.call(
240 248 "DOM.requestNode", objectId=self._object_id
@@ -258,20 +266,31 @@ class CDPElement:
258 266
259 267 async def send_keys(self, text: str):
260 268 """Type text into the element."""
261 # Focus the element first
262 await self._tab._session.call(
263 "DOM.focus", objectId=self._object_id
264 )
265 # Dispatch each character as a key event
269 # Focus via JS first — more reliable than DOM.focus for contenteditable
270 # editors (e.g. ChatGPT's ProseMirror-based prompt box).
271 try:
272 await self._tab._session.call(
273 "Runtime.callFunctionOn",
274 objectId=self._object_id,
275 functionDeclaration="function(){this.focus();}",
276 )
277 except Exception:
278 pass
279 try:
280 await self._tab._session.call("DOM.focus", objectId=self._object_id)
281 except Exception:
282 pass
283 # keyDown/keyUp alone don't insert text into contenteditable elements —
284 # Input.insertText is required to actually mutate the editor content.
266 285 for char in text:
267 286 await self._tab._session.call(
268 287 "Input.dispatchKeyEvent",
269 type="keyDown",
270 text=char,
288 type="rawKeyDown",
271 289 key=char,
272 290 code="",
273 291 windowsVirtualKeyCode=ord(char) if char.isascii() else 0,
274 292 )
293 await self._tab._session.call("Input.insertText", text=char)
275 294 await self._tab._session.call(
276 295 "Input.dispatchKeyEvent",
277 296 type="keyUp",
@@ -306,8 +325,8 @@ class CDPTab:
306 325 return self
307 326
308 327 async def reload(self):
309 """Reload the current page."""
310 await self._session.call("Page.reload")
328 """Reload the current page and wait for it to finish loading."""
329 await self._session.reload()
311 330
312 331 async def close(self):
313 332 """Close this tab."""
@@ -567,10 +586,11 @@ class CDPBrowser:
567 586 """
568 587
569 588 def __init__(self, headless: Optional[bool] = None, proxy: str = None,
570 user_data_dir: str = None):
589 user_data_dir: str = None, browser_args: Optional[List[str]] = None):
571 590 self.headless = headless
572 591 self.proxy = proxy
573 592 self.user_data_dir = user_data_dir
593 self.browser_args = browser_args
574 594 self._tabs: List[CDPTab] = []
575 595 self.cdp = _CdpShim
576 596 self.cookies = _BrowserCookies(self)
@@ -585,7 +605,9 @@ class CDPBrowser:
585 605
586 606 Emulates ``browser.get(url)`` from nodriver.
587 607 """
588 session = CDPSession(headless=self.headless)
608 session = CDPSession(
609 headless=self.headless, proxy=self.proxy, browser_args=self.browser_args
610 )
589 611 await session.start()
590 612 tab = CDPTab(session)
591 613 self._tabs.append(tab)