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

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

from g4f.Provider.needs_auth.DeepSeek import (
    CHAT_COMPLETION_ENDPOINT,
    CHAT_SESSION_CONTINUE_ENDPOINT,
    CHAT_SESSION_DELETE_ENDPOINT,
    CHAT_SESSION_RESUME_STREAM_ENDPOINT,
    DeepSeek,
    _build_chat_headers,
    _extract_chat_session_id,
    iter_deepseek_sse,
)
from g4f.errors import MissingAuthError, ResponseError
from g4f.providers.response import (
    FinishReason,
    JsonConversation,
    JsonRequest,
    Reasoning,
)


DEEPSEEK_MODULE = importlib.import_module("g4f.Provider.needs_auth.DeepSeek")


def sse_event(event: str, payload: dict) -> list[bytes]:
    return [
        f"event: {event}".encode(),
        f"data: {json.dumps(payload)}".encode(),
        b"",
    ]


class FakeStreamResponse:
    def __init__(
        self,
        lines: list[bytes] | None = None,
        *,
        payload: dict | None = None,
        content_type: str = "text/event-stream",
    ):
        self.status = 200
        self.headers = {"content-type": content_type}
        self.lines = lines or []
        self.payload = payload

    async def __aenter__(self):
        return self

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

    async def iter_lines(self):
        for line in self.lines:
            yield line

    async def json(self):
        return self.payload


class FakeStreamSession:
    def __init__(self, responses: list[FakeStreamResponse]):
        self.responses = iter(responses)
        self.post_calls = []

    def post(self, url, **kwargs):
        self.post_calls.append((url, kwargs))
        return next(self.responses)

    async def __aenter__(self):
        return self

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


class DeepSeekSSETest(unittest.IsolatedAsyncioTestCase):
    async def test_explicit_authorization_survives_cookie_lookup(self):
        with patch.object(
            DEEPSEEK_MODULE, "get_cookies", return_value={}
        ), patch.object(
            DEEPSEEK_MODULE, "get_headers", return_value={}
        ):
            generator = DeepSeek.create_async_generator(
                "deepseek-v3",
                [{"role": "user", "content": "hello"}],
                cookies=None,
                headers={"Authorization": "Bearer supplied"},
            )
            request = await generator.__anext__()
            await generator.aclose()

        self.assertIsInstance(request, JsonRequest)

    async def test_reasoning_effort_none_disables_r1_thinking(self):
        generator = DeepSeek.create_async_generator(
            "deepseek-r1",
            [{"role": "user", "content": "hello"}],
            cookies={},
            headers={"Authorization": "Bearer supplied"},
            reasoning_effort="none",
        )
        request = await generator.__anext__()
        await generator.aclose()

        self.assertFalse(request.get_dict()["thinking_enabled"])

    async def test_get_quota_reports_missing_auth_when_headers_are_unavailable(self):
        with patch.object(
            DEEPSEEK_MODULE, "get_cookies", return_value={"sid": "x"}
        ), patch.object(
            DEEPSEEK_MODULE, "get_headers", return_value=None
        ):
            with self.assertRaises(MissingAuthError):
                await DeepSeek.get_quota()

    async def test_non_sse_resume_code_22_emits_full_message(self):
        response = FakeStreamResponse(
            payload={
                "data": {
                    "biz_code": 22,
                    "biz_msg": "resume returned full message",
                    "biz_data": {
                        "response": {
                            "message_id": 7,
                            "status": "FINISHED",
                            "fragments": [
                                {"type": "THINK", "content": "thought"},
                                {"type": "RESPONSE", "content": "answer"},
                            ],
                        }
                    },
                }
            },
            content_type="application/json",
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                )
            ]

        self.assertEqual(
            "".join(str(chunk) for chunk in chunks if isinstance(chunk, Reasoning)),
            "thought",
        )
        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "answer",
        )
        self.assertEqual(
            [chunk.reason for chunk in chunks if isinstance(chunk, FinishReason)],
            ["stop"],
        )
        self.assertEqual(conversation.parent_message_id, 7)

    async def test_finished_reasoning_only_stream_resumes_full_message_once(self):
        reasoning_only_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "p": "response/fragments",
                    "o": "APPEND",
                    "v": [{"type": "THINK", "content": "thought"}],
                },
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        full_message_response = FakeStreamResponse(
            payload={
                "data": {
                    "biz_code": 22,
                    "biz_msg": "resume returned full message",
                    "biz_data": {
                        "response": {
                            "message_id": 4,
                            "status": "FINISHED",
                            "fragments": [
                                {"type": "THINK", "content": "thought"},
                                {"type": "RESPONSE", "content": "answer"},
                            ],
                        }
                    },
                }
            },
            content_type="application/json",
        )
        session = FakeStreamSession(
            [reasoning_only_response, full_message_response]
        )
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    max_resume_attempts=3,
                )
            ]

        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [CHAT_COMPLETION_ENDPOINT, CHAT_SESSION_RESUME_STREAM_ENDPOINT],
        )
        self.assertEqual(
            session.post_calls[1][1]["json"],
            {"chat_session_id": "session-1", "message_id": 4},
        )
        self.assertEqual(
            "".join(str(chunk) for chunk in chunks if isinstance(chunk, Reasoning)),
            "thought",
        )
        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "answer",
        )
        self.assertEqual(
            [chunk.reason for chunk in chunks if isinstance(chunk, FinishReason)],
            ["stop"],
        )
        self.assertEqual(conversation.parent_message_id, 4)

    async def test_finished_stream_raises_when_resumed_message_has_no_response(self):
        reasoning_only_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "p": "response/fragments",
                    "o": "APPEND",
                    "v": [{"type": "THINK", "content": "thought"}],
                },
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        full_message_response = FakeStreamResponse(
            payload={
                "data": {
                    "biz_code": 22,
                    "biz_msg": "resume returned full message",
                    "biz_data": {
                        "response": {
                            "message_id": 4,
                            "status": "FINISHED",
                            "fragments": [
                                {"type": "THINK", "content": "thought"},
                            ],
                        }
                    },
                }
            },
            content_type="application/json",
        )
        session = FakeStreamSession(
            [reasoning_only_response, full_message_response]
        )
        conversation = JsonConversation(parent_message_id=None)
        chunks = []

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), self.assertRaisesRegex(
            ResponseError,
            "DeepSeek finished without a response",
        ):
            async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
            ):
                chunks.append(chunk)

        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [CHAT_COMPLETION_ENDPOINT, CHAT_SESSION_RESUME_STREAM_ENDPOINT],
        )
        self.assertEqual(
            "".join(str(chunk) for chunk in chunks if isinstance(chunk, Reasoning)),
            "thought",
        )
        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "",
        )

    async def test_finished_empty_stream_does_not_resume_when_disabled(self):
        response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), self.assertRaisesRegex(
            ResponseError,
            "DeepSeek finished without a response",
        ):
            async for _chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    max_resume_attempts=0,
            ):
                pass

        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [CHAT_COMPLETION_ENDPOINT],
        )

    async def test_empty_response_resume_counts_toward_resume_limit(self):
        reasoning_only_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        interrupted_resume = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
        )
        session = FakeStreamSession(
            [reasoning_only_response, interrupted_resume]
        )
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), self.assertRaisesRegex(
            RuntimeError,
            "did not close normally after 1 resume attempt",
        ):
            async for _chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    max_resume_attempts=1,
            ):
                pass

        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [CHAT_COMPLETION_ENDPOINT, CHAT_SESSION_RESUME_STREAM_ENDPOINT],
        )

    async def test_full_message_without_response_always_raises(self):
        response = FakeStreamResponse(
            payload={
                "data": {
                    "biz_code": 22,
                    "biz_msg": "resume returned full message",
                    "biz_data": {
                        "response": {
                            "message_id": 4,
                            "status": "WIP",
                            "fragments": [
                                {"type": "THINK", "content": "thought"},
                            ],
                        }
                    },
                }
            },
            content_type="application/json",
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), self.assertRaisesRegex(
            ResponseError,
            "DeepSeek finished without a response",
        ):
            async for _chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
            ):
                pass

    async def test_indexed_response_fragment_switches_from_reasoning(self):
        response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "v": {
                        "response": {
                            "message_id": 4,
                            "fragments": [
                                {"type": "THINK", "content": "thought"},
                            ],
                        }
                    }
                },
            )
            + sse_event(
                "message",
                {
                    "p": "response/fragments/-1",
                    "o": "APPEND",
                    "v": {"type": "RESPONSE", "content": ""},
                },
            )
            + sse_event(
                "message",
                {
                    "p": "response/fragments/-1",
                    "o": "SET",
                    "v": {"type": "RESPONSE", "content": "answer"},
                },
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                )
            ]

        self.assertEqual(len(session.post_calls), 1)
        self.assertEqual(
            "".join(str(chunk) for chunk in chunks if isinstance(chunk, Reasoning)),
            "thought",
        )
        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "answer",
        )

    async def test_indexed_content_uses_its_fragment_type(self):
        response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "v": {
                        "response": {
                            "message_id": 4,
                            "fragments": [
                                {"type": "THINK", "content": ""},
                                {"type": "RESPONSE", "content": ""},
                            ],
                        }
                    }
                },
            )
            + sse_event(
                "message",
                {
                    "p": "response/fragments/-1/type",
                    "o": "SET",
                    "v": "TEMPLATE_RESPONSE",
                },
            )
            + sse_event(
                "message",
                {
                    "p": "response/fragments/0/content",
                    "o": "APPEND",
                    "v": "thought",
                },
            )
            + sse_event(
                "message",
                {
                    "p": "response/fragments/1/content",
                    "o": "APPEND",
                    "v": "answer",
                },
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                )
            ]

        self.assertEqual(
            "".join(str(chunk) for chunk in chunks if isinstance(chunk, Reasoning)),
            "thought",
        )
        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "answer",
        )

    async def test_non_sse_business_error_exposes_biz_message(self):
        response = FakeStreamResponse(
            payload={
                "data": {
                    "biz_code": 40101,
                    "biz_msg": "authorization expired",
                    "biz_data": None,
                }
            },
            content_type="application/json",
        )
        session = FakeStreamSession([response])

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            with self.assertRaisesRegex(RuntimeError, "authorization expired"):
                _ = [
                    chunk
                    async for chunk in DeepSeek.iter_chat_stream(
                        session,
                        JsonConversation(parent_message_id=None),
                        {"chat_session_id": "session-1", "prompt": "prompt"},
                    )
                ]

    async def test_session_creation_exposes_biz_message(self):
        session = FakeStreamSession(
            [
                FakeStreamResponse(
                    payload={
                        "data": {
                            "biz_code": 50301,
                            "biz_msg": "session unavailable",
                            "biz_data": None,
                        }
                    },
                    content_type="application/json",
                )
            ]
        )
        generator = DeepSeek.create_async_generator(
            "deepseek-v3",
            [{"role": "user", "content": "hello"}],
            cookies={},
            headers={"Authorization": "Bearer supplied"},
        )

        await generator.__anext__()
        with patch.object(
            DEEPSEEK_MODULE, "StreamSession", return_value=session
        ), patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ):
            with self.assertRaisesRegex(RuntimeError, "session unavailable"):
                await generator.__anext__()

    async def test_delete_session_uses_one_observed_request(self):
        session = FakeStreamSession(
            [
                FakeStreamResponse(
                    payload={"data": {"biz_code": 0, "biz_data": {}}},
                    content_type="application/json",
                )
            ]
        )

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            deleted = await DeepSeek.delete_chat_session(
                session,
                "session-1",
                {"authorization": "Bearer redacted"},
            )

        self.assertTrue(deleted)
        self.assertEqual(
            session.post_calls,
            [
                (
                    CHAT_SESSION_DELETE_ENDPOINT,
                    {
                        "headers": {"authorization": "Bearer redacted"},
                        "json": {"chat_session_id": "session-1"},
                    },
                )
            ],
        )

    async def test_delete_session_business_error_returns_false_without_retry(self):
        session = FakeStreamSession(
            [
                FakeStreamResponse(
                    payload={
                        "data": {
                            "biz_code": 50001,
                            "biz_msg": "delete unavailable",
                            "biz_data": None,
                        }
                    },
                    content_type="application/json",
                )
            ]
        )

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            deleted = await DeepSeek.delete_chat_session(
                session,
                "session-1",
                {"authorization": "Bearer redacted"},
            )

        self.assertIs(deleted, False)
        self.assertEqual(len(session.post_calls), 1)

    def test_extracts_current_and_legacy_chat_session_ids(self):
        self.assertEqual(
            _extract_chat_session_id(
                {
                    "data": {
                        "biz_data": {
                            "chat_session": {"id": "current-session-id"}
                        }
                    }
                }
            ),
            "current-session-id",
        )
        self.assertEqual(
            _extract_chat_session_id(
                {"data": {"biz_data": {"id": "legacy-session-id"}}}
            ),
            "legacy-session-id",
        )

    async def test_parser_preserves_close_event(self):
        response = FakeStreamResponse(
            sse_event("message", {"v": "chunk"})
            + sse_event("update_session", {"updated_at": 1})
            + sse_event("close", {"auto_resume": False})
        )

        events = [event async for event in iter_deepseek_sse(response)]

        self.assertEqual(
            [event_type for event_type, _payload in events],
            ["message", "update_session", "close"],
        )

    async def test_incomplete_closed_stream_continues_same_message(self):
        first_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "A"},
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "INCOMPLETE"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        continued_response = FakeStreamResponse(
            sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "B"},
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([first_response, continued_response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), patch.object(
            DEEPSEEK_MODULE.debug, "log"
        ) as log:
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    {"x-ds-pow-response": "completion-pow"},
                    auto_continue=True,
                    max_continue_attempts=3,
                    max_resume_attempts=3,
                )
            ]

        log_output = "\n".join(
            str(call.args[0]) for call in log.call_args_list if call.args
        )

        self.assertEqual("".join(map(str, chunks)), "AB")
        self.assertEqual(conversation.parent_message_id, 4)
        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [CHAT_COMPLETION_ENDPOINT, CHAT_SESSION_CONTINUE_ENDPOINT],
        )
        self.assertEqual(
            session.post_calls[1][1]["json"],
            {
                "chat_session_id": "session-1",
                "message_id": 4,
                "fallback_to_resume": True,
            },
        )
        self.assertIn(
            "DeepSeekAuth: Stream status: status=INCOMPLETE source=patch "
            "operation=SET interpretation=requires_continue",
            log_output,
        )
        self.assertIn(
            "DeepSeekAuth: Stream closed: status=INCOMPLETE action=continue "
            "reason=incomplete_status finish_reason=none auto_resume=false "
            "click_behavior=none response_chars=1 reasoning_chars=0 "
            "message_id_present=true",
            log_output,
        )

    async def test_incomplete_without_operation_continues(self):
        incomplete_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "A"},
            )
            + sse_event(
                "message",
                {"p": "response/status", "v": "INCOMPLETE"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        continued_response = FakeStreamResponse(
            sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "B"},
            )
            + sse_event(
                "message",
                {"p": "response/status", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([incomplete_response, continued_response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), patch.object(
            DEEPSEEK_MODULE.debug, "log"
        ) as log:
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    auto_continue=True,
                )
            ]

        log_output = "\n".join(
            str(call.args[0]) for call in log.call_args_list if call.args
        )

        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "AB",
        )
        self.assertEqual(
            [chunk.reason for chunk in chunks if isinstance(chunk, FinishReason)],
            ["stop"],
        )
        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [CHAT_COMPLETION_ENDPOINT, CHAT_SESSION_CONTINUE_ENDPOINT],
        )
        self.assertIn(
            "DeepSeekAuth: Stream status: status=INCOMPLETE source=patch "
            "operation=none interpretation=requires_continue",
            log_output,
        )
        self.assertIn(
            "DeepSeekAuth: Stream closed: status=INCOMPLETE action=continue "
            "reason=incomplete_status finish_reason=none auto_resume=false "
            "click_behavior=none response_chars=1 reasoning_chars=0 "
            "message_id_present=true",
            log_output,
        )

    async def test_incomplete_snapshot_continues_without_prior_status_patch(self):
        incomplete_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "v": {
                        "response": {
                            "message_id": 4,
                            "status": "INCOMPLETE",
                            "fragments": [
                                {"type": "RESPONSE", "content": "A"}
                            ],
                        }
                    }
                },
            )
            + sse_event("close", {"auto_resume": False})
        )
        continued_response = FakeStreamResponse(
            sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "B"},
            )
            + sse_event(
                "message",
                {"p": "response/status", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([incomplete_response, continued_response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), patch.object(
            DEEPSEEK_MODULE.debug, "log"
        ) as log:
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    auto_continue=True,
                )
            ]

        log_output = "\n".join(
            str(call.args[0]) for call in log.call_args_list if call.args
        )
        self.assertEqual("".join(map(str, chunks)), "AB")
        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [CHAT_COMPLETION_ENDPOINT, CHAT_SESSION_CONTINUE_ENDPOINT],
        )
        self.assertIn(
            "DeepSeekAuth: Stream status: status=INCOMPLETE source=snapshot "
            "operation=none interpretation=requires_continue",
            log_output,
        )

    async def test_unclosed_stream_resumes_until_close_without_snapshot_duplicates(self):
        interrupted_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "A"},
            )
        )
        resumed_but_unclosed_response = FakeStreamResponse(
            sse_event(
                "message",
                {
                    "v": {
                        "response": {
                            "message_id": 4,
                            "fragments": [
                                {"type": "RESPONSE", "content": "AB"}
                            ],
                        }
                    }
                },
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
        )
        finally_closed_response = FakeStreamResponse(
            sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession(
            [
                interrupted_response,
                resumed_but_unclosed_response,
                finally_closed_response,
            ]
        )
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(
            DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
        ), patch.object(
            DEEPSEEK_MODULE.debug, "log"
        ) as log:
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    {"x-ds-pow-response": "completion-pow"},
                    max_continue_attempts=3,
                    max_resume_attempts=3,
                )
            ]

        log_output = "\n".join(
            str(call.args[0]) for call in log.call_args_list if call.args
        )

        self.assertEqual("".join(map(str, chunks)), "AB")
        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [
                CHAT_COMPLETION_ENDPOINT,
                CHAT_SESSION_RESUME_STREAM_ENDPOINT,
                CHAT_SESSION_RESUME_STREAM_ENDPOINT,
            ],
        )
        for _url, kwargs in session.post_calls[1:]:
            self.assertEqual(
                kwargs["json"],
                {"chat_session_id": "session-1", "message_id": 4},
            )
        self.assertIn(
            "DeepSeekAuth: Stream ended without close: status=none "
            "message_id_present=true error=none",
            log_output,
        )
        self.assertIn(
            "DeepSeekAuth: Resuming interrupted response stream: "
            "action=resume_stream attempt=1",
            log_output,
        )

    async def test_unclosed_incomplete_stream_resumes_before_it_continues(self):
        interrupted_response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "A"},
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "INCOMPLETE"},
            )
        )
        resumed_response = FakeStreamResponse(
            sse_event(
                "message",
                {
                    "v": {
                        "response": {
                            "message_id": 4,
                            "status": "INCOMPLETE",
                            "fragments": [
                                {"type": "RESPONSE", "content": "A"}
                            ],
                        }
                    }
                },
            )
            + sse_event("update_session", {"v": "must-not-be-output"})
            + sse_event("close", {"auto_resume": False})
        )
        continued_response = FakeStreamResponse(
            sse_event(
                "message",
                {"p": "response/fragments/-1/content", "o": "APPEND", "v": "B"},
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession(
            [interrupted_response, resumed_response, continued_response]
        )
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                    {"x-ds-pow-response": "completion-pow"},
                    max_continue_attempts=3,
                    max_resume_attempts=3,
                )
            ]

        self.assertEqual("".join(map(str, chunks)), "AB")
        self.assertEqual(
            [url for url, _kwargs in session.post_calls],
            [
                CHAT_COMPLETION_ENDPOINT,
                CHAT_SESSION_RESUME_STREAM_ENDPOINT,
                CHAT_SESSION_CONTINUE_ENDPOINT,
            ],
        )

    async def test_terminal_statuses_are_exposed_as_finish_reasons(self):
        expected_reasons = {
            "FINISHED": "stop",
            "CONTENT_FILTER": "content_filter",
            "CONTEXT_LENGTH_EXCEEDED": "length",
            "INCOMPLETE": "incomplete",
            "WIP": "wip",
            "TIMEOUT": "timeout",
        }

        for status, expected_reason in expected_reasons.items():
            with self.subTest(status=status):
                response = FakeStreamResponse(
                    sse_event("ready", {"response_message_id": 4})
                    + sse_event(
                        "message",
                        {
                            "p": "response/fragments",
                            "o": "APPEND",
                            "v": [{"type": "RESPONSE", "content": "answer"}],
                        },
                    )
                    + sse_event(
                        "message",
                        {"p": "response/status", "o": "SET", "v": status},
                    )
                    + sse_event("close", {"auto_resume": False})
                )
                session = FakeStreamSession([response])
                conversation = JsonConversation(parent_message_id=None)

                with patch.object(
                    DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock
                ):
                    chunks = [
                        chunk
                        async for chunk in DeepSeek.iter_chat_stream(
                            session,
                            conversation,
                            {"chat_session_id": "session-1", "prompt": "prompt"},
                            auto_continue=False,
                        )
                    ]

                finish_reasons = [
                    chunk.reason for chunk in chunks if isinstance(chunk, FinishReason)
                ]
                self.assertEqual(finish_reasons, [expected_reason])
                self.assertEqual(len(session.post_calls), 1)

    async def test_fragment_types_separate_response_reasoning_and_metadata(self):
        response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "v": {
                        "response": {
                            "message_id": 4,
                            "fragments": [
                                {"type": "REQUEST", "content": "prompt"},
                                {"type": "THINK", "content": "think"},
                                {"type": "SEARCH", "content": "search"},
                                {"type": "TOOL_FIND", "content": "tool"},
                                {"type": "RESPONSE", "content": "answer"},
                                {
                                    "type": "TEMPLATE_RESPONSE",
                                    "content": "template",
                                },
                                {"type": "FILE", "content": "file"},
                                {"type": "TIP", "content": "tip"},
                                {"type": "READ_LINK", "content": "link"},
                                {"type": "UNKNOWN", "content": "unknown"},
                            ],
                        }
                    }
                },
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                )
            ]

        self.assertEqual(
            "".join(str(chunk) for chunk in chunks if isinstance(chunk, Reasoning)),
            "thinksearchtool",
        )
        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "answertemplate",
        )

    async def test_set_fragments_emits_only_snapshot_delta(self):
        response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "p": "response/fragments",
                    "o": "SET",
                    "v": [{"type": "RESPONSE", "content": "A"}],
                },
            )
            + sse_event(
                "message",
                {
                    "p": "response/fragments",
                    "o": "SET",
                    "v": [{"type": "RESPONSE", "content": "AB"}],
                },
            )
            + sse_event(
                "message",
                {"p": "response/status", "o": "SET", "v": "FINISHED"},
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                )
            ]

        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "AB",
        )

    async def test_batch_applies_append_fragments_and_status(self):
        response = FakeStreamResponse(
            sse_event("ready", {"response_message_id": 4})
            + sse_event(
                "message",
                {
                    "o": "BATCH",
                    "v": [
                        {
                            "p": "response/fragments",
                            "o": "APPEND",
                            "v": [
                                {
                                    "type": "TEMPLATE_RESPONSE",
                                    "content": "answer",
                                }
                            ],
                        },
                        {
                            "p": "response/status",
                            "o": "SET",
                            "v": "FINISHED",
                        },
                    ],
                },
            )
            + sse_event("close", {"auto_resume": False})
        )
        session = FakeStreamSession([response])
        conversation = JsonConversation(parent_message_id=None)

        with patch.object(DEEPSEEK_MODULE, "raise_for_status", new_callable=AsyncMock):
            chunks = [
                chunk
                async for chunk in DeepSeek.iter_chat_stream(
                    session,
                    conversation,
                    {"chat_session_id": "session-1", "prompt": "prompt"},
                )
            ]

        self.assertEqual(
            "".join(chunk for chunk in chunks if isinstance(chunk, str)),
            "answer",
        )
        self.assertEqual(
            [chunk.reason for chunk in chunks if isinstance(chunk, FinishReason)],
            ["stop"],
        )

    def test_chat_headers_forward_hif_but_drop_stale_pow(self):
        headers = _build_chat_headers(
            {
                "Authorization": "Bearer redacted",
                "X-Hif-Leim": "hif-redacted",
                "X-Hif-Dliq": "dliq-redacted",
                "X-Client-Version": "2.4.0",
                "X-Ds-Pow-Response": "stale-pow",
            },
            "Bearer redacted",
        )

        self.assertEqual(headers["x-hif-leim"], "hif-redacted")
        self.assertEqual(headers["x-hif-dliq"], "dliq-redacted")
        self.assertEqual(headers["x-client-version"], "2.4.0")
        self.assertEqual(headers["referer"], "https://chat.deepseek.com/a/chat/")
        self.assertNotIn("x-app-version", headers)
        self.assertNotIn("x-ds-pow-response", headers)

    def test_completion_payload_matches_current_web_contract(self):
        conversation = JsonConversation(parent_message_id=None)
        conversation.chat_session_id = "session-1"

        payload = DEEPSEEK_MODULE._build_completion_payload(
            conversation,
            prompt="hello",
            model_type="default",
            ref_file_ids=["file-1"],
            thinking_enabled=True,
            search_enabled=False,
        )

        self.assertEqual(
            payload,
            {
                "action": None,
                "chat_session_id": "session-1",
                "parent_message_id": None,
                "model_type": "default",
                "prompt": "hello",
                "ref_file_ids": ["file-1"],
                "thinking_enabled": True,
                "search_enabled": False,
                "preempt": False,
            },
        )

        conversation.parent_message_id = 9
        self.assertEqual(
            DEEPSEEK_MODULE._build_completion_payload(
                conversation,
                prompt="follow up",
                model_type="default",
                ref_file_ids=[],
                thinking_enabled=False,
                search_enabled=True,
            )["parent_message_id"],
            9,
        )


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