XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 0
UTF-8
from __future__ import annotations

import asyncio
import importlib
import json
import unittest
from unittest.mock import AsyncMock, patch

from g4f.Provider.needs_auth.Gemini import (
    ACCOUNT_STATUS_AVAILABLE,
    ACCOUNT_STATUS_UNAUTHENTICATED,
    MODEL_HEADER_KEY,
    Gemini,
    _build_model_headers,
    _extract_gemini_error_code,
    _extract_reasoning,
    _has_authenticated_session,
    _is_xsrf_error,
    _iter_response_lines,
    _normalize_messages,
    _parse_account_models,
    _parse_google_frames,
    _resolve_gemini_conversation,
    _resolve_gemini_prompt,
    _resolve_model,
    Conversation,
)
from g4f.errors import MissingAuthError, ResponseError, ResponseStatusError
from g4f.models import ModelRegistry
from g4f.providers.any_model_map import model_map

GEMINI_MODULE = importlib.import_module("g4f.Provider.needs_auth.Gemini")


def build_account_response(status: int) -> tuple[str, dict[str, dict]]:
    body = [None] * 18
    body[14] = status
    body[15] = [
        ["fbb127bbb056c959", "Flash", "All-around help"],
        ["5bf011840784117a", "Thinking", "Solves complex problems"],
        ["9d8ca3786ebdfbea", "Pro", "Advanced math & code"],
    ]
    body[16] = []
    body[17] = []
    response = json.dumps([["wrb.fr", "otAQ7b", json.dumps(body)]])
    parsed_status, registry = _parse_account_models(response)
    assert parsed_status == status
    return response, registry


class GeminiHelpersTest(unittest.TestCase):
    def test_parse_account_models_and_availability(self):
        _, registry = build_account_response(ACCOUNT_STATUS_UNAUTHENTICATED)

        self.assertTrue(registry["fbb127bbb056c959"]["available"])
        self.assertFalse(registry["5bf011840784117a"]["available"])
        self.assertFalse(registry["9d8ca3786ebdfbea"]["available"])

    def test_missing_account_status_means_available(self):
        body = [None] * 18
        body[15] = [["e6fa609c3fa255c0", "Pro", "Advanced model"]]
        response = json.dumps([["wrb.fr", "otAQ7b", json.dumps(body)]])

        status, registry = _parse_account_models(response)

        self.assertEqual(status, ACCOUNT_STATUS_AVAILABLE)
        self.assertTrue(registry["e6fa609c3fa255c0"]["available"])

    def test_parse_utf16_length_prefixed_frame(self):
        raw = json.dumps([["wrb.fr", "rpc", "emoji: 😀"]], ensure_ascii=False)
        payload = f"\n{raw}\n"
        utf16_length = len(payload.encode("utf-16-le")) // 2

        frames, remaining = _parse_google_frames(f"{utf16_length}{payload}")

        self.assertEqual(frames[0][0], "wrb.fr")
        self.assertEqual(remaining, "")

    def test_model_header_capacity_fields(self):
        field_12 = json.loads(_build_model_headers("model", 4, 12)[MODEL_HEADER_KEY])
        field_13 = json.loads(_build_model_headers("model", 2, 13)[MODEL_HEADER_KEY])

        self.assertEqual(field_12[11], 4)
        self.assertIsNone(field_13[11])
        self.assertEqual(field_13[12], 2)

    def test_current_models_match_authenticated_web_request_fields(self):
        expected = {
            "gemini-3.6-flash": (1, 1),
            "gemini-3.5-flash-lite": (6, 1),
            "gemini-3.1-pro": (3, 1),
        }

        for requested, (mode, request_96) in expected.items():
            with self.subTest(model=requested):
                model, expanded = _resolve_model(requested)
                request = Gemini.build_request(
                    "test", "en", model, expanded, request_uuid="test-request"
                )
                self.assertEqual(len(request), 97)
                self.assertEqual(request[6], [1])
                self.assertIsNone(request[9])
                self.assertEqual(request[17], [[0]])
                self.assertEqual(request[41], [1])
                self.assertEqual(request[68], 2)
                self.assertEqual(request[79], mode)
                self.assertEqual(request[80], 1)
                self.assertEqual(request[91], 0)
                self.assertEqual(request[96], request_96)

    def test_expanded_thinking_is_independent_for_every_current_model(self):
        for requested in (
            "gemini-3.6-flash",
            "gemini-3.5-flash-lite",
            "gemini-3.1-pro",
        ):
            with self.subTest(model=requested):
                model, _ = _resolve_model(requested)
                request = Gemini.build_request(
                    "test", "en", model, True, request_uuid="test-request"
                )
                self.assertEqual(request[17], [[0]])
                self.assertEqual(request[80], 2)
                self.assertEqual(request[96], 1)

    def test_legacy_thinking_depths_map_to_binary_expanded_option(self):
        for requested_depth in range(5):
            with self.subTest(depth=requested_depth):
                model, expanded = _resolve_model(
                    f"gemini-3.5-flash-thinking@think={requested_depth}"
                )
                self.assertEqual(model, "gemini-3.6-flash")
                self.assertEqual(expanded, requested_depth <= 2)

    def test_conversation_turn_counter_uses_request_field_17(self):
        for model, expanded in (
            ("gemini-3.6-flash", False),
            ("gemini-3.6-flash", True),
            ("gemini-3.5-flash-lite", False),
            ("gemini-3.1-pro", False),
        ):
            with self.subTest(model=model, expanded=expanded):
                conversation = Conversation(
                    "conversation-id",
                    "response-id",
                    "choice-id",
                    model,
                    turn_index=2,
                )

                request = Gemini.build_request(
                    "test",
                    "en",
                    model,
                    expanded,
                    conversation=conversation,
                    request_uuid="test-request",
                )

                self.assertEqual(request[17], [[2]])
                self.assertEqual(request[96], 0)

    def test_legacy_model_names_resolve_to_current_modes(self):
        expected = {
            "gemini-2.0": "gemini-3.6-flash",
            "gemini-2.0-flash": "gemini-3.6-flash",
            "gemini-2.0-flash-thinking": "gemini-3.6-flash",
            "gemini-2.0-flash-thinking-with-apps": "gemini-3.6-flash",
            "gemini-2.5-flash": "gemini-3.6-flash",
            "gemini-3.5-flash": "gemini-3.6-flash",
            "gemini-3.5-flash-thinking": "gemini-3.6-flash",
            "gemini-auto": "gemini-3.6-flash",
            "gemini-2.5-pro": "gemini-3.1-pro",
            "gemini-3.1-flash-lite": "gemini-3.5-flash-lite",
            "gemini-flash-lite": "gemini-3.5-flash-lite",
            "gemini-3.5-flash-thinking-lite": "gemini-3.5-flash-lite",
        }

        for legacy_name, current_name in expected.items():
            with self.subTest(model=legacy_name):
                resolved, _ = _resolve_model(legacy_name)
                self.assertEqual(resolved, current_name)

    def test_unknown_model_lists_supported_models(self):
        with self.assertRaises(ValueError) as context:
            _resolve_model("gemini-3.7-flash")

        message = str(context.exception)
        self.assertIn("Unknown Gemini model: gemini-3.7-flash", message)
        self.assertIn("Supported models:", message)
        self.assertIn("gemini-3.6-flash", message)
        self.assertIn("gemini-3.5-flash-lite", message)

    def test_public_model_registry_exposes_current_models(self):
        self.assertEqual(ModelRegistry.get("gemini-auto").name, "gemini-auto")
        for model in (
            "gemini-3.6-flash",
            "gemini-3.5-flash-lite",
            "gemini-3.1-pro",
            "gemini-3.5-flash-thinking",
            "gemini-auto",
            "gemini-3.5-flash-thinking-lite",
            "gemini-flash-lite",
        ):
            with self.subTest(model=model):
                self.assertEqual(ModelRegistry.get(model).name, model)
                self.assertEqual(model_map[model]["Gemini"], model)

    def test_prompt_only_requests_accept_missing_messages(self):
        messages = _normalize_messages(None)

        self.assertEqual(messages, [])
        self.assertEqual(
            _resolve_gemini_prompt(messages, "prompt-only request", None),
            "prompt-only request",
        )
        with self.assertRaises(TypeError):
            _normalize_messages({"role": "user"})

    def test_anonymous_followup_uses_full_message_history(self):
        messages = [
            {"role": "user", "content": "first turn"},
            {"role": "assistant", "content": "first answer"},
            {"role": "user", "content": "second turn"},
        ]
        conversation = Conversation(
            "conversation-id",
            "response-id",
            "choice-id",
            "gemini-3.6-flash",
        )

        self.assertFalse(_has_authenticated_session({}))
        resolved = _resolve_gemini_conversation(
            conversation,
            "gemini-3.6-flash",
            {},
        )
        prompt = _resolve_gemini_prompt(messages, None, resolved)
        request = Gemini.build_request(
            prompt,
            "en",
            "gemini-3.6-flash",
            False,
            conversation=resolved,
            request_uuid="test-request",
        )

        self.assertIsNone(resolved)
        self.assertIn("first turn", prompt)
        self.assertIn("first answer", prompt)
        self.assertIn("second turn", prompt)
        self.assertEqual(request[2][:3], ["", "", ""])

    def test_authenticated_followup_keeps_conversation_handle(self):
        conversation = Conversation(
            "conversation-id",
            "response-id",
            "choice-id",
            "gemini-3.5-flash",
        )
        cookies = {"__Secure-1PSID": "session"}

        self.assertTrue(_has_authenticated_session(cookies))
        self.assertIs(
            _resolve_gemini_conversation(
                conversation,
                "gemini-3.5-flash",
                cookies,
            ),
            conversation,
        )
        self.assertIsNone(
            _resolve_gemini_conversation(
                conversation,
                "gemini-3.1-pro",
                cookies,
            )
        )

    def test_xsrf_response_is_retryable(self):
        error = ResponseStatusError('Response 400: [["er",null,{"reason":"xsrf"}]]')

        self.assertTrue(_is_xsrf_error(error, 400))
        self.assertFalse(_is_xsrf_error(error, 401))
        self.assertFalse(_is_xsrf_error(ResponseStatusError("Response 400"), 400))

    def test_extract_reasoning_from_dedicated_field(self):
        candidate = [None] * 38
        candidate[37] = [["private reasoning"]]
        response = [None] * 5
        response[4] = [candidate]

        self.assertEqual(_extract_reasoning(response), "private reasoning")

    def test_extract_structured_error_code(self):
        frame = ["wrb.fr", None, None, None, None, [None, None, [[None, [1037]]]]]

        self.assertEqual(_extract_gemini_error_code(frame), 1037)

    def test_reject_silent_model_fallback(self):
        _, registry = build_account_response(ACCOUNT_STATUS_UNAUTHENTICATED)

        class ProbeGemini(Gemini):
            _account_status = ACCOUNT_STATUS_UNAUTHENTICATED
            _account_models = registry

        with self.assertRaises(MissingAuthError):
            ProbeGemini.validate_model_access("gemini-3.1-pro")
        ProbeGemini.validate_model_access("gemini-3.6-flash")
        ProbeGemini.validate_model_access("gemini-3.5-flash")
        ProbeGemini.validate_model_access("gemini-3.1-pro", allow_model_fallback=True)

    def test_dynamic_headers_only_for_available_pro(self):
        _, registry = build_account_response(ACCOUNT_STATUS_AVAILABLE)

        class ProbeGemini(Gemini):
            _account_status = ACCOUNT_STATUS_AVAILABLE
            _account_models = registry

        pro_header = json.loads(
            ProbeGemini.get_model_headers("gemini-3.1-pro")[MODEL_HEADER_KEY]
        )
        self.assertEqual(pro_header[4], "9d8ca3786ebdfbea")
        self.assertEqual(ProbeGemini.get_model_headers("gemini-3.5-flash"), {})
        self.assertEqual(ProbeGemini.get_model_headers("gemini-3.5-flash-thinking"), {})


class GeminiStreamTest(unittest.IsolatedAsyncioTestCase):
    async def test_public_generator_retries_xsrf_with_prompt_only_request(self):
        class FakeResponse:
            def __init__(self, status, content=b"[]\n"):
                self.status = status
                self.headers = {}
                self.released = False
                self.content = self
                self._content = content

            async def __aenter__(self):
                return self

            async def __aexit__(self, _exc_type, _exc, _traceback):
                return False

            async def iter_any(self):
                yield self._content

            def release(self):
                self.released = True

        class FakeSession:
            def __init__(self, responses):
                self.responses = iter(responses)
                self.calls = []

            async def __aenter__(self):
                return self

            async def __aexit__(self, _exc_type, _exc, _traceback):
                return False

            async def post(self, _url, **kwargs):
                self.calls.append(
                    {
                        "data": dict(kwargs["data"]),
                        "params": dict(kwargs["params"]),
                        "cookies": dict(kwargs["cookies"]),
                        "cookies_object": kwargs["cookies"],
                    }
                )
                return next(self.responses)

        class ProbeGemini(Gemini):
            auto_refresh = False
            _cookies = None
            _metadata_cookie_key = None
            _metadata_auth_user = None
            _metadata_fetched_at = 0
            _account_status = None
            _account_models = {}
            _account_models_fetched_at = 0
            _snlm0e = None
            _sid = None

        first_response = FakeResponse(400)
        session = FakeSession([first_response, FakeResponse(200)])
        source_cookies = {
            "__Secure-1PSID": "session",
            "__Secure-1PSIDTS": "old",
        }
        metadata_fetches = 0

        async def fetch_metadata(_session, cookies, _auth_user=None):
            nonlocal metadata_fetches
            metadata_fetches += 1
            self.assertIsNot(cookies, source_cookies)
            ProbeGemini._snlm0e = f"token-{metadata_fetches}"
            ProbeGemini._sid = f"sid-{metadata_fetches}"
            ProbeGemini._bl = f"build-{metadata_fetches}"

        async def check_status(response):
            if response.status == 400:
                raise ResponseStatusError(
                    'Response 400: [["er",null,{"reason":"xsrf"}]]'
                )

        with patch.object(
            GEMINI_MODULE,
            "ClientSession",
            return_value=session,
        ):
            with patch.object(
                ProbeGemini,
                "fetch_snlm0e",
                new_callable=AsyncMock,
                side_effect=fetch_metadata,
            ) as fetch:
                with patch.object(
                    ProbeGemini,
                    "fetch_account_models",
                    new_callable=AsyncMock,
                ):
                    with patch.object(
                        ProbeGemini,
                        "upload_images",
                        new_callable=AsyncMock,
                        return_value=[],
                    ):
                        with patch.object(
                            GEMINI_MODULE,
                            "raise_for_status",
                            side_effect=check_status,
                        ):
                            generator = ProbeGemini.create_async_generator(
                                model="gemini-3.6-flash",
                                messages=None,
                                prompt="prompt-only request",
                                cookies=source_cookies,
                                expanded_thinking=True,
                                max_retries=1,
                            )
                            await generator.__anext__()
                            await generator.aclose()

        self.assertEqual(fetch.await_count, 2)
        self.assertEqual(len(session.calls), 2)
        self.assertTrue(first_response.released)
        self.assertEqual(session.calls[0]["data"]["at"], "token-1")
        self.assertEqual(session.calls[0]["params"]["bl"], "build-1")
        self.assertEqual(session.calls[0]["params"]["f.sid"], "sid-1")
        self.assertEqual(session.calls[1]["data"]["at"], "token-2")
        self.assertEqual(session.calls[1]["params"]["bl"], "build-2")
        self.assertEqual(session.calls[1]["params"]["f.sid"], "sid-2")
        self.assertIs(
            session.calls[0]["cookies_object"],
            session.calls[1]["cookies_object"],
        )
        self.assertIsNot(session.calls[0]["cookies_object"], source_cookies)
        self.assertEqual(source_cookies["__Secure-1PSIDTS"], "old")
        request = json.loads(json.loads(session.calls[1]["data"]["f.req"])[1])
        self.assertEqual(request[0][0], "prompt-only request")
        self.assertEqual(request[79], 1)
        self.assertEqual(request[80], 2)
        self.assertEqual(request[96], 1)

    async def test_auto_refresh_waits_before_rotating(self):
        with patch.object(
            GEMINI_MODULE.asyncio,
            "sleep",
            new_callable=AsyncMock,
            side_effect=asyncio.CancelledError(),
        ) as sleep:
            with patch.object(
                GEMINI_MODULE,
                "rotate_1psidts",
                new_callable=AsyncMock,
            ) as rotate:
                with self.assertRaises(asyncio.CancelledError):
                    await Gemini.start_auto_refresh(
                        cookies={"__Secure-1PSID": "session"}
                    )

        sleep.assert_awaited_once_with(Gemini.refresh_interval)
        rotate.assert_not_awaited()

    async def test_auto_refresh_ignores_missing_cookies(self):
        with patch.object(
            GEMINI_MODULE,
            "rotate_1psidts",
            new_callable=AsyncMock,
        ) as rotate:
            await Gemini.start_auto_refresh(cookies=None)

            rotate.assert_not_awaited()

    async def test_auto_refresh_uses_cookie_snapshot(self):
        source = {
            "__Secure-1PSID": "session",
            "__Secure-1PSIDTS": "old",
        }
        observed = {}

        async def rotate(_url, cookies, _proxy):
            observed["same_object"] = cookies is source
            observed["sidts"] = cookies.get("__Secure-1PSIDTS")
            raise asyncio.CancelledError()

        previous_cookies = Gemini._cookies
        Gemini._cookies = None
        try:
            with patch.object(
                GEMINI_MODULE.asyncio,
                "sleep",
                new_callable=AsyncMock,
            ) as sleep:
                with patch.object(
                    GEMINI_MODULE,
                    "rotate_1psidts",
                    new_callable=AsyncMock,
                    side_effect=rotate,
                ) as rotate_mock:
                    with self.assertRaises(asyncio.CancelledError):
                        await Gemini.start_auto_refresh(cookies=source)

            sleep.assert_awaited_once_with(Gemini.refresh_interval)
            rotate_mock.assert_awaited_once()
            self.assertFalse(observed["same_object"])
            self.assertEqual(observed["sidts"], "old")
            self.assertEqual(source["__Secure-1PSIDTS"], "old")
        finally:
            Gemini._cookies = previous_cookies

    async def test_stream_idle_timeout(self):
        class SlowContent:
            async def iter_any(self):
                await asyncio.sleep(0.05)
                yield b"late\n"

        with self.assertRaises(ResponseError):
            async for _ in _iter_response_lines(SlowContent(), idle_timeout=0.01):
                pass

    async def test_stream_reassembles_split_unicode_line(self):
        data = '[["wrb.fr","rpc","😀"]]\n'.encode()

        class SplitContent:
            async def iter_any(self):
                for chunk in (data[:20], data[20:23], data[23:]):
                    yield chunk

        lines = [line async for line in _iter_response_lines(SplitContent(), 1)]
        self.assertEqual(json.loads(lines[0])[0][2], "😀")


if __name__ == "__main__":
    unittest.main()