CoolFace
Modelpublic

deepseek-ai/DeepSeek-V4-Flash-Vision-Exp

sourceHugging Facemitupdated 25d agoView on Hugging Face
928likes925kdownloads
encoding_dsv4.py958 linesDownload Raw Back to encoding
1"""2DeepSeek-V4 Text and Vision Encoding3 4A self-contained implementation for encoding/decoding DeepSeek-V4 chat messages5with tool calling, thinking mode, quick instruction tasks, and image content blocks.6"""7 8from typing import Any, Dict, List, Union, Optional, Tuple9import copy10import json11import re12 13# ============================================================14# Special Tokens15# ============================================================16 17bos_token: str = "<|begin▁of▁sentence|>"18eos_token: str = "<|end▁of▁sentence|>"19thinking_start_token: str = "<think>"20thinking_end_token: str = "</think>"21dsml_token: str = "|DSML|"22 23USER_SP_TOKEN = "<|User|>"24ASSISTANT_SP_TOKEN = "<|Assistant|>"25LATEST_REMINDER_SP_TOKEN = "<|latest_reminder|>"26IMAGE_PLACEHOLDER = "<|deepseek_image|>"27IMAGE_TAG_PATTERN = re.compile(r"<image>(.*?)</image>", re.DOTALL)28 29# Task special tokens for internal classification tasks30DS_TASK_SP_TOKENS = {31    "action": "<|action|>",32    "query": "<|query|>",33    "authority": "<|authority|>",34    "domain": "<|domain|>",35    "title": "<|title|>",36    "read_url": "<|read_url|>",37}38VALID_TASKS = set(DS_TASK_SP_TOKENS.keys())39 40# ============================================================41# Templates42# ============================================================43 44system_msg_template: str = "{content}"45user_msg_template: str = "{content}"46latest_reminder_msg_template: str = "{content}"47assistant_msg_template: str = "{reasoning}{content}{tool_calls}" + eos_token48assistant_msg_wo_eos_template: str = "{reasoning}{content}{tool_calls}"49thinking_template: str = "{reasoning_content}"50 51response_format_template: str = (52    "## Response Format:\n\nYou MUST strictly adhere to the following schema to reply:\n{schema}"53)54tool_call_template: str = (55    "<{dsml_token}invoke name=\"{name}\">\n{arguments}\n</{dsml_token}invoke>"56)57tool_calls_template = (58    "<{dsml_token}{tc_block_name}>\n{tool_calls}\n</{dsml_token}{tc_block_name}>"59)60tool_calls_block_name: str = "tool_calls"61 62tool_output_template: str = (63    "<tool_result>{content}</tool_result>"64)65 66# Reasoning effort levels. In thinking mode, the prompt for the selected level is67# prepended at the very beginning of the conversation. `low` is the default and68# adds nothing.69REASONING_EFFORT_PROMPTS: Dict[str, str] = {70    "low": "",71    "high": (72        "Reasoning Effort: Absolute maximum with no shortcuts permitted.\n"73        "You MUST be very thorough in your thinking and comprehensively decompose the problem to resolve the root cause, rigorously stress-testing your logic against all potential paths, edge cases, and adversarial scenarios.\n"74        "Explicitly write out your entire deliberation process, documenting every intermediate step, considered alternative, and rejected hypothesis to ensure absolutely no assumption is left unchecked.\n\n"75    ),76    "max": (77        "Reasoning Effort: Beyond maximum — exhaustive, relentless, and uncompromising.\n"78        "You MUST reason with the utmost depth and rigor, leaving absolutely nothing to chance: exhaustively decompose the problem into its most fundamental components, trace every causal chain to its root, and resolve the underlying cause rather than any surface symptom.\n"79        "Do not stop reasoning until you have independently verified the solution from multiple angles and are certain that no assumption remains unchecked and no error remains undiscovered.\n\n"80    ),81}82DEFAULT_REASONING_EFFORT = "low"83 84TOOLS_TEMPLATE = """## Tools85 86You have access to a set of tools to help answer the user's question. You can invoke tools by writing a "<{dsml_token}tool_calls>" block like the following:87 88<{dsml_token}tool_calls>89<{dsml_token}invoke name="$TOOL_NAME">90<{dsml_token}parameter name="$PARAMETER_NAME" string="true|false">$PARAMETER_VALUE</{dsml_token}parameter>91...92</{dsml_token}invoke>93<{dsml_token}invoke name="$TOOL_NAME2">94...95</{dsml_token}invoke>96</{dsml_token}tool_calls>97 98String parameters should be specified as is and set `string="true"`. For all other types (numbers, booleans, arrays, objects), pass the value in JSON format and set `string="false"`.99 100If thinking_mode is enabled (triggered by {thinking_start_token}), you MUST output your complete reasoning inside {thinking_start_token}...{thinking_end_token} BEFORE any tool calls or final response.101 102Otherwise, output directly after {thinking_end_token} with tool calls or final response.103 104### Available Tool Schemas105 106{tool_schemas}107 108You MUST strictly follow the above defined tool name and parameter schemas to invoke tool calls.109"""110 111# ============================================================112# Utility Functions113# ============================================================114 115def to_json(value: Any) -> str:116    """Serialize a value to JSON string."""117    try:118        return json.dumps(value, ensure_ascii=False)119    except:120        return json.dumps(value, ensure_ascii=True)121 122 123def tools_from_openai_format(tools):124    """Extract function definitions from OpenAI-format tool list."""125    return [tool["function"] for tool in tools]126 127 128def tool_calls_from_openai_format(tool_calls):129    """Convert OpenAI-format tool calls to internal format."""130    return [131        {132            "name": tool_call["function"]["name"],133            "arguments": tool_call["function"]["arguments"],134        }135        for tool_call in tool_calls136    ]137 138 139def tool_calls_to_openai_format(tool_calls):140    """Convert internal tool calls to OpenAI format."""141    return [142        {143            "type": "function",144            "function": {145                "name": tool_call["name"],146                "arguments": tool_call["arguments"],147            }148        }149        for tool_call in tool_calls150    ]151 152 153def encode_arguments_to_dsml(tool_call: Dict[str, str]) -> str:154    """155    Encode tool call arguments into DSML parameter format.156 157    Args:158        tool_call: Dict with "name" and "arguments" (JSON string) keys.159 160    Returns:161        DSML-formatted parameter string.162    """163    p_dsml_template = '<{dsml_token}parameter name="{key}" string="{is_str}">{value}</{dsml_token}parameter>'164    P_dsml_strs = []165 166    try:167        arguments = json.loads(tool_call["arguments"])168    except Exception as err:169        arguments = {"arguments": tool_call["arguments"]}170 171    for k, v in arguments.items():172        p_dsml_str = p_dsml_template.format(173            dsml_token=dsml_token,174            key=k,175            is_str="true" if isinstance(v, str) else "false",176            value=v if isinstance(v, str) else to_json(v),177        )178        P_dsml_strs.append(p_dsml_str)179 180    return "\n".join(P_dsml_strs)181 182 183def decode_dsml_to_arguments(tool_name: str, tool_args: Dict[str, Tuple[str, str]]) -> Dict[str, str]:184    """185    Decode DSML parameters back to a tool call dict.186 187    Args:188        tool_name: Name of the tool.189        tool_args: Dict mapping param_name -> (value, is_string_flag).190 191    Returns:192        Dict with "name" and "arguments" (JSON string) keys.193    """194    def _decode_value(key: str, value: str, string: str):195        if string == "true":196            value = to_json(value)197        return f"{to_json(key)}: {value}"198 199    tool_args_json = "{" + ", ".join([_decode_value(k, v, string=is_str) for k, (v, is_str) in tool_args.items()]) + "}"200    return dict(name=tool_name, arguments=tool_args_json)201 202 203def render_tools(tools: List[Dict[str, Union[str, Dict[str, Any]]]]) -> str:204    """205    Render tool schemas into the system prompt format.206 207    Args:208        tools: List of tool schema dicts (each with name, description, parameters).209 210    Returns:211        Formatted tools section string.212    """213    tools_json = [to_json(t) for t in tools]214 215    return TOOLS_TEMPLATE.format(216        tool_schemas="\n".join(tools_json),217        dsml_token=dsml_token,218        thinking_start_token=thinking_start_token,219        thinking_end_token=thinking_end_token,220    )221 222 223def find_last_user_index(messages: List[Dict[str, Any]]) -> int:224    """Find the index of the last user/developer message."""225    last_user_index = -1226    for idx in range(len(messages) - 1, -1, -1):227        if messages[idx].get("role") in ["user", "developer"]:228            last_user_index = idx229            break230    return last_user_index231 232 233# ============================================================234# Message Rendering235# ============================================================236 237def render_message(index: int, messages: List[Dict[str, Any]], thinking_mode: str, drop_thinking: bool = True, reasoning_effort: Optional[str] = None) -> str:238    """239    Render a single message at the given index into its encoded string form.240 241    This is the core function that converts each message in the conversation242    into the DeepSeek-V4 format.243 244    Args:245        index: Index of the message to render.246        messages: Full list of messages in the conversation.247        thinking_mode: Either "chat" or "thinking".248        drop_thinking: Whether to drop reasoning content from earlier turns.249        reasoning_effort: Reasoning effort level, one of "low", "high", "max".250            None is treated as "low".251 252    Returns:253        Encoded string for this message.254    """255    assert 0 <= index < len(messages)256    assert thinking_mode in ["chat", "thinking"], f"Invalid thinking_mode `{thinking_mode}`"257 258    prompt = ""259    msg = messages[index]260    last_user_idx = find_last_user_index(messages)261 262    role = msg.get("role")263    content = msg.get("content")264    tools = msg.get("tools")265    response_format = msg.get("response_format")266    tool_calls = msg.get("tool_calls")267    reasoning_content = msg.get("reasoning_content")268    wo_eos = msg.get("wo_eos", False)269 270    if tools:271        tools = tools_from_openai_format(tools)272    if tool_calls:273        tool_calls = tool_calls_from_openai_format(tool_calls)274 275    # Reasoning effort prefix (only at index 0 in thinking mode; "low" adds nothing)276    reasoning_effort = reasoning_effort or DEFAULT_REASONING_EFFORT277    assert reasoning_effort in REASONING_EFFORT_PROMPTS, \278        f"Invalid reasoning effort: {reasoning_effort}, expected one of {list(REASONING_EFFORT_PROMPTS)}"279    if index == 0 and thinking_mode == "thinking":280        prompt += REASONING_EFFORT_PROMPTS[reasoning_effort]281 282    if role == "system":283        prompt += system_msg_template.format(content=content or "")284        if tools:285            prompt += "\n\n" + render_tools(tools)286        if response_format:287            prompt += "\n\n" + response_format_template.format(schema=to_json(response_format))288 289    elif role == "developer":290        assert content, f"Invalid message for role `{role}`: {msg}"291 292        content_developer = USER_SP_TOKEN293        content_developer += content294 295        if tools:296            content_developer += "\n\n" + render_tools(tools)297        if response_format:298            content_developer += "\n\n" + response_format_template.format(schema=to_json(response_format))299 300        prompt += user_msg_template.format(content=content_developer)301 302    elif role == "user":303        prompt += USER_SP_TOKEN304 305        # Handle content blocks (tool results mixed with text)306        content_blocks = msg.get("content_blocks")307        if content_blocks:308            parts = []309            for block in content_blocks:310                block_type = block.get("type")311                if block_type == "text":312                    parts.append(block.get("text", ""))313                elif block_type == "tool_result":314                    tool_content = block.get("content", "")315                    if isinstance(tool_content, list):316                        text_parts = []317                        for b in tool_content:318                            if b.get("type") == "text":319                                text_parts.append(b.get("text", ""))320                            else:321                                text_parts.append(f"[Unsupported {b.get('type')}]")322                        tool_content = "\n\n".join(text_parts)323                    parts.append(tool_output_template.format(content=tool_content))324                else:325                    parts.append(f"[Unsupported {block_type}]")326            prompt += "\n\n".join(parts)327        else:328            prompt += content or ""329 330    elif role == "latest_reminder":331        prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)332 333    elif role == "tool":334        raise NotImplementedError("deepseek_v4 merges tool messages into user; please preprocess with merge_tool_messages()")335 336    elif role == "assistant":337        thinking_part = ""338        tc_content = ""339 340        if tool_calls:341            tc_list = [342                tool_call_template.format(343                    dsml_token=dsml_token,344                    name=tc.get("name"),345                    arguments=encode_arguments_to_dsml(tc)346                )347                for tc in tool_calls348            ]349            tc_content += '\n\n' + tool_calls_template.format(350                dsml_token=dsml_token,351                tool_calls="\n".join(tc_list),352                tc_block_name=tool_calls_block_name,353            )354 355        summary_content = content or ""356        rc = reasoning_content or ""357 358        # Check if previous message has a task - if so, this is a task output (no thinking)359        prev_has_task = index - 1 >= 0 and messages[index - 1].get("task") is not None360 361        if thinking_mode == "thinking" and not prev_has_task:362            if not drop_thinking or index > last_user_idx:363                thinking_part = thinking_template.format(reasoning_content=rc) + thinking_end_token364            else:365                thinking_part = ""366 367        if wo_eos:368            prompt += assistant_msg_wo_eos_template.format(369                reasoning=thinking_part,370                content=summary_content,371                tool_calls=tc_content,372            )373        else:374            prompt += assistant_msg_template.format(375                reasoning=thinking_part,376                content=summary_content,377                tool_calls=tc_content,378            )379    else:380        raise NotImplementedError(f"Unknown role: {role}")381 382    # Append transition tokens based on what follows383    if index + 1 < len(messages) and messages[index + 1].get("role") not in ["assistant", "latest_reminder"]:384        return prompt385 386    task = messages[index].get("task")387    if task is not None:388        # Task special token for internal classification tasks389        assert task in VALID_TASKS, f"Invalid task: '{task}'. Valid tasks are: {list(VALID_TASKS)}"390        task_sp_token = DS_TASK_SP_TOKENS[task]391 392        if task != "action":393            # Non-action tasks: append task sp token directly after the message394            prompt += task_sp_token395        else:396            # Action task: append Assistant + thinking token + action sp token397            prompt += ASSISTANT_SP_TOKEN398            prompt += thinking_end_token if thinking_mode != "thinking" else thinking_start_token399            prompt += task_sp_token400 401    elif messages[index].get("role") in ["user", "developer"]:402        # Normal generation: append Assistant + thinking token403        prompt += ASSISTANT_SP_TOKEN404        if not drop_thinking and thinking_mode == "thinking":405            prompt += thinking_start_token406        elif drop_thinking and thinking_mode == "thinking" and index >= last_user_idx:407            prompt += thinking_start_token408        else:409            prompt += thinking_end_token410 411    return prompt412 413 414# ============================================================415# Preprocessing416# ============================================================417 418def merge_tool_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:419    """420    Merge tool messages into the preceding user message using content_blocks format.421 422    DeepSeek-V4 does not have a standalone "tool" role; instead, tool results423    are encoded as <tool_result> blocks within user messages.424 425    This function converts a standard OpenAI-format conversation (with separate426    "tool" role messages) into V4 format where tool results are merged into427    user messages.428 429    Args:430        messages: List of message dicts in OpenAI format.431 432    Returns:433        Processed message list with tool messages merged into user messages.434    """435    merged: List[Dict[str, Any]] = []436 437    for msg in messages:438        msg = copy.deepcopy(msg)439        role = msg.get("role")440 441        if role == "tool":442            # Convert tool message to a user message with tool_result block443            tool_block = {444                "type": "tool_result",445                "tool_use_id": msg.get("tool_call_id", ""),446                "content": msg.get("content", ""),447            }448            # Merge into previous message if it's already a user (merged tool)449            if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1]:450                merged[-1]["content_blocks"].append(tool_block)451            else:452                merged.append({453                    "role": "user",454                    "content_blocks": [tool_block],455                })456        elif role == "user":457            content_blocks = msg.get("content_blocks")458            if content_blocks is None:459                content_blocks = [{"type": "text", "text": msg.get("content", "")}]460            if merged and merged[-1].get("role") == "user" and "content_blocks" in merged[-1] and merged[-1].get("task") is None:461                merged[-1]["content_blocks"].extend(content_blocks)462            else:463                # Preserve structured content and all message-level metadata.464                new_msg = msg465                new_msg["content_blocks"] = content_blocks466                merged.append(new_msg)467        else:468            merged.append(msg)469 470    return merged471 472 473def sort_tool_results_by_call_order(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:474    """475    Sort tool_result blocks within user messages by the order of tool_calls476    in the preceding assistant message.477 478    Args:479        messages: Preprocessed message list (after merge_tool_messages).480 481    Returns:482        Message list with sorted tool result blocks.483    """484    last_tool_call_order: Dict[str, int] = {}485 486    for msg in messages:487        role = msg.get("role")488        if role == "assistant" and msg.get("tool_calls"):489            last_tool_call_order = {}490            for idx, tc in enumerate(msg["tool_calls"]):491                tc_id = tc.get("id") or tc.get("function", {}).get("id", "")492                if tc_id:493                    last_tool_call_order[tc_id] = idx494 495        elif role == "user" and msg.get("content_blocks"):496            tool_blocks = [b for b in msg["content_blocks"] if b.get("type") == "tool_result"]497            if len(tool_blocks) > 1 and last_tool_call_order:498                sorted_blocks = sorted(499                    tool_blocks,500                    key=lambda b: last_tool_call_order.get(b.get("tool_use_id", ""), 0)501                )502                sorted_idx = 0503                new_blocks = []504                for block in msg["content_blocks"]:505                    if block.get("type") == "tool_result":506                        new_blocks.append(sorted_blocks[sorted_idx])507                        sorted_idx += 1508                    else:509                        new_blocks.append(block)510                msg["content_blocks"] = new_blocks511 512    return messages513 514 515# ============================================================516# Main Encoding Function517# ============================================================518 519def _encode_messages_text(520    messages: List[Dict[str, Any]],521    thinking_mode: str,522    context: Optional[List[Dict[str, Any]]] = None,523    drop_thinking: bool = True,524    add_default_bos_token: bool = True,525    reasoning_effort: Optional[str] = None,526) -> str:527    """528    Encode a list of messages into the DeepSeek-V4 prompt format.529 530    This is the main entry point for encoding conversations. It handles:531    - BOS token insertion532    - Thinking mode with optional reasoning content dropping533    - Tool message merging into user messages534    - Multi-turn conversation context535 536    Args:537        messages: List of message dicts to encode.538        thinking_mode: Either "chat" or "thinking".539        context: Optional preceding context messages (already encoded prefix).540        drop_thinking: If True, drop reasoning_content from earlier assistant turns541                      (only keep reasoning for messages after the last user message).542        add_default_bos_token: Whether to prepend BOS token at conversation start.543        reasoning_effort: Reasoning effort level, one of "low", "high", "max".544            Only takes effect in thinking mode. None is treated as "low".545 546    Returns:547        The encoded prompt string.548    """549    context = context if context else []550 551    # Preprocess: merge tool messages and sort tool results552    messages = merge_tool_messages(messages)553    messages = sort_tool_results_by_call_order(context + messages)[len(context):]554    if context:555        context = merge_tool_messages(context)556        context = sort_tool_results_by_call_order(context)557 558    full_messages = context + messages559 560    prompt = bos_token if add_default_bos_token and len(context) == 0 else ""561 562    # Resolve drop_thinking: if any message has tools defined, don't drop thinking563    effective_drop_thinking = drop_thinking564    if any(m.get("tools") for m in full_messages):565        effective_drop_thinking = False566 567    if thinking_mode == "thinking" and effective_drop_thinking:568        full_messages = _drop_thinking_messages(full_messages)569        # After dropping, recalculate how many messages to render570        # (context may have shrunk too)571        num_to_render = len(full_messages) - len(_drop_thinking_messages(context))572        context_len = len(full_messages) - num_to_render573    else:574        num_to_render = len(messages)575        context_len = len(context)576 577    for idx in range(num_to_render):578        prompt += render_message(579            idx + context_len,580            full_messages,581            thinking_mode=thinking_mode,582            drop_thinking=effective_drop_thinking,583            reasoning_effort=reasoning_effort,584        )585 586    return prompt587 588 589def _drop_thinking_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:590    """591    Drop reasoning_content and non-essential messages before the last user message.592 593    Behavior:594    - Messages with role in ["user", "system", "tool", "latest_reminder"] are always kept.595    - Messages at or after the last user index are always kept.596    - Assistant messages before the last user get reasoning_content removed.597    - Developer messages before the last user are dropped entirely.598    """599    last_user_idx = find_last_user_index(messages)600    result = []601    keep_roles = {"user", "system", "tool", "latest_reminder", "direct_search_results"}602 603    for idx, msg in enumerate(messages):604        role = msg.get("role")605        if role in keep_roles or idx >= last_user_idx:606            result.append(msg)607        elif role == "assistant":608            msg = copy.copy(msg)609            msg.pop("reasoning_content", None)610            result.append(msg)611        # developer and other roles before last_user_idx are dropped612 613    return result614 615 616# ============================================================617# Vision Message Preprocessing618# ============================================================619 620def parse_tagged_text(text: str) -> Union[str, List[Dict[str, Any]]]:621    """Convert ``<image>path</image>`` text into standard content blocks."""622    matches = list(IMAGE_TAG_PATTERN.finditer(text))623    remaining = IMAGE_TAG_PATTERN.sub("", text)624    if "<image>" in remaining or "</image>" in remaining:625        raise ValueError("Malformed <image>path</image> tag")626    if not matches:627        return text628 629    blocks: List[Dict[str, Any]] = []630    cursor = 0631    for match in matches:632        if match.start() > cursor:633            blocks.append({"type": "text", "text": text[cursor:match.start()]})634        path = match.group(1)635        if not path:636            raise ValueError("Image path must not be empty")637        blocks.append({638            "type": "image_url",639            "image_url": {"url": path},640        })641        cursor = match.end()642    if cursor < len(text):643        blocks.append({"type": "text", "text": text[cursor:]})644    return blocks645 646 647def _is_image_block(block: Dict[str, Any]) -> bool:648    """Return whether a content block is an OpenAI/Anthropic/internal image."""649    return isinstance(block, dict) and block.get("type") in ("image", "image_url")650 651 652def _extract_image(block: Dict[str, Any]) -> Dict[str, Any]:653    """Normalize a supported image block into an internal image record."""654    record: Dict[str, Any] = {"type": "image"}655    if block.get("type") == "image_url":656        image_url = block.get("image_url")657        if isinstance(image_url, str):658            record["url"] = image_url659        else:660            record["url"] = (image_url or {}).get("url", "")661    else:662        for key in ("source", "url", "data"):663            if key in block:664                record[key] = block[key]665    if not any(record.get(key) for key in ("source", "url", "data")):666        raise ValueError("Image block does not contain a valid source")667    return record668 669 670def _process_image_blocks(671    blocks: List[Any], image_placeholder: str = IMAGE_PLACEHOLDER672) -> Tuple[List[Any], List[Dict[str, Any]]]:673    """Replace image blocks and collect their records in one ordered traversal."""674    new_blocks: List[Any] = []675    images: List[Dict[str, Any]] = []676    for block in blocks:677        if not isinstance(block, dict):678            new_blocks.append(block)679            continue680        if _is_image_block(block):681            new_blocks.append({"type": "text", "text": image_placeholder})682            images.append(_extract_image(block))683        elif block.get("type") == "tool_result" and isinstance(block.get("content"), list):684            block = copy.copy(block)685            block["content"], nested_images = _process_image_blocks(686                block["content"], image_placeholder)687            new_blocks.append(block)688            images.extend(nested_images)689        elif block.get("type") == "text":690            text = block.get("text") or ""691            if IMAGE_PLACEHOLDER in text:692                raise ValueError(693                    f"Text block contains image placeholder '{IMAGE_PLACEHOLDER}': "694                    f"'{text[:100]}'. Images should be separate content blocks."695                )696            new_blocks.append(block)697        else:698            new_blocks.append(block)699    return new_blocks, images700 701 702def _validate_no_image_sp_tokens(msg: Dict[str, Any]) -> None:703    """Reject user-supplied image placeholder tokens in textual fields."""704    content = msg.get("content")705    if isinstance(content, str) and IMAGE_PLACEHOLDER in content:706        raise ValueError(707            f"Message content contains image special token '{IMAGE_PLACEHOLDER}'. "708            "Images should be provided as image content blocks."709        )710    reasoning_content = msg.get("reasoning_content")711    if isinstance(reasoning_content, str) and IMAGE_PLACEHOLDER in reasoning_content:712        raise ValueError(713            f"reasoning_content contains image special token '{IMAGE_PLACEHOLDER}'"714        )715 716 717def process_image_messages(718    messages: List[Dict[str, Any]],719) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:720    """Normalize image blocks and return their records in prompt order."""721    processed: List[Dict[str, Any]] = []722    images: List[Dict[str, Any]] = []723    for msg in messages:724        msg = copy.deepcopy(msg)725        _validate_no_image_sp_tokens(msg)726 727        if isinstance(msg.get("content"), list) and "content_blocks" not in msg:728            msg["content_blocks"] = msg.pop("content")729 730        if msg.get("content_blocks"):731            msg["content_blocks"], message_images = _process_image_blocks(732                msg["content_blocks"])733            images.extend(message_images)734            if not isinstance(msg.get("content"), str):735                texts = [736                    block.get("text", "")737                    for block in msg["content_blocks"]738                    if isinstance(block, dict) and block.get("type") == "text"739                ]740                msg["content"] = "\n\n".join(texts)741 742        processed.append(msg)743    return processed, images744 745 746def encode_messages(747    messages: List[Dict[str, Any]],748    thinking_mode: str,749    context: Optional[List[Dict[str, Any]]] = None,750    drop_thinking: bool = True,751    add_default_bos_token: bool = True,752    reasoning_effort: Optional[str] = None,753    return_multi_modal_data: bool = False,754) -> Any:755    """Encode text or multimodal messages through one canonical public entrypoint.756 757    Text-only calls preserve the original string-returning API. When758    return_multi_modal_data is true, the result is ``(prompt, media_data)``.759    """760    context = context or []761    processed_context, _ = process_image_messages(context) if context else ([], [])762    processed_messages, images = process_image_messages(messages)763    prompt = _encode_messages_text(764        processed_messages,765        thinking_mode=thinking_mode,766        context=processed_context if processed_context else None,767        drop_thinking=drop_thinking,768        add_default_bos_token=add_default_bos_token,769        reasoning_effort=reasoning_effort,770    )771    if return_multi_modal_data:772        return prompt, {"images": images}773    return prompt774 775 776def load_cases(input_file: str) -> List[Dict[str, Any]]:777    """Load one or more OpenAI-format conversation cases from JSON."""778    with open(input_file) as file:779        data = json.load(file)780    if isinstance(data, dict):781        data = [data]782    elif data and isinstance(data[0], dict) and "role" in data[0]:783        data = [{"messages": data}]784 785    cases = []786    for case in data:787        messages = copy.deepcopy(case["messages"])788        if "tools" in case:789            if not messages:790                raise ValueError("A case with tools must contain at least one message")791            messages[0]["tools"] = case["tools"]792        cases.append({793            "messages": messages,794            "context": case.get("context"),795            "thinking_mode": case.get("thinking_mode"),796            "reasoning_effort": case.get("reasoning_effort"),797        })798    return cases799 800 801def encode_case(802    case: Dict[str, Any], thinking_mode: str803) -> Tuple[str, List[Dict[str, Any]]]:804    """Encode one JSON case and return its current-turn image records."""805    prompt, media_data = encode_messages(806        case["messages"],807        thinking_mode=case.get("thinking_mode") or thinking_mode,808        context=case.get("context"),809        reasoning_effort=case.get("reasoning_effort"),810        return_multi_modal_data=True,811    )812    return prompt, media_data["images"]813 814 815# ============================================================816# Parsing (Decoding model output)817# ============================================================818 819def _read_until_stop(index: int, text: str, stop: List[str]) -> Tuple[int, str, Optional[str]]:820    """821    Read text from index until one of the stop strings is found.822 823    Returns:824        Tuple of (new_index, content_before_stop, matched_stop_string_or_None).825    """826    min_pos = len(text)827    matched_stop = None828 829    for s in stop:830        pos = text.find(s, index)831        if pos != -1 and pos < min_pos:832            min_pos = pos833            matched_stop = s834 835    if matched_stop:836        content = text[index:min_pos]837        return min_pos + len(matched_stop), content, matched_stop838    else:839        content = text[index:]840        return len(text), content, None841 842 843def parse_tool_calls(index: int, text: str) -> Tuple[int, Optional[str], List[Dict[str, str]]]:844    """845    Parse DSML tool calls from text starting at the given index.846 847    Args:848        index: Starting position in text.849        text: The full text to parse.850 851    Returns:852        Tuple of (new_index, last_stop_token, list_of_tool_call_dicts).853        Each tool call dict has "name" and "arguments" keys.854    """855    tool_calls: List[Dict[str, Any]] = []856    stop_token = None857    tool_calls_end_token = f"</{dsml_token}{tool_calls_block_name}>"858 859    while index < len(text):860        index, _, stop_token = _read_until_stop(index, text, [f"<{dsml_token}invoke", tool_calls_end_token])861        if _ != ">\n":862            raise ValueError(f"Tool call format error: expected '>\\n' but got '{_}'")863 864        if stop_token == tool_calls_end_token:865            break866 867        if stop_token is None:868            raise ValueError("Missing special token in tool calls")869 870        index, tool_name_content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])871 872        p_tool_name = re.findall(r'^\s*name="(.*?)">\n$', tool_name_content, flags=re.DOTALL)873        if len(p_tool_name) != 1:874            raise ValueError(f"Tool name format error: '{tool_name_content}'")875        tool_name = p_tool_name[0]876 877        tool_args: Dict[str, Tuple[str, str]] = {}878        while stop_token == f"<{dsml_token}parameter":879            index, param_content, stop_token = _read_until_stop(index, text, [f"/{dsml_token}parameter"])880 881            param_kv = re.findall(r'^ name="(.*?)" string="(true|false)">(.*?)<$', param_content, flags=re.DOTALL)882            if len(param_kv) != 1:883                raise ValueError(f"Parameter format error: '{param_content}'")884            param_name, string, param_value = param_kv[0]885 886            if param_name in tool_args:887                raise ValueError(f"Duplicate parameter name: '{param_name}'")888            tool_args[param_name] = (param_value, string)889 890            index, content, stop_token = _read_until_stop(index, text, [f"<{dsml_token}parameter", f"</{dsml_token}invoke"])891            if content != ">\n":892                raise ValueError(f"Parameter format error: expected '>\\n' but got '{content}'")893 894        tool_call = decode_dsml_to_arguments(tool_name=tool_name, tool_args=tool_args)895        tool_calls.append(tool_call)896 897    return index, stop_token, tool_calls898 899 900def parse_message_from_completion_text(text: str, thinking_mode: str) -> Dict[str, Any]:901    """902    Parse a model completion text into a structured assistant message.903 904    This function takes the raw text output from the model (a single assistant turn)905    and extracts:906    - reasoning_content (thinking block)907    - content (summary/response)908    - tool_calls (if any)909 910    NOTE: This function is designed to parse only correctly formatted strings and911    will raise ValueError for malformed output.912 913    Args:914        text: The raw completion text (including EOS token).915        thinking_mode: Either "chat" or "thinking".916 917    Returns:918        Dict with keys: "role", "content", "reasoning_content", "tool_calls".919        tool_calls are in OpenAI format.920    """921    summary_content, reasoning_content, tool_calls = "", "", []922    index, stop_token = 0, None923    tool_calls_start_token = f"\n\n<{dsml_token}{tool_calls_block_name}"924 925    is_thinking = thinking_mode == "thinking"926    is_tool_calling = False927 928    if is_thinking:929        index, content_delta, stop_token = _read_until_stop(index, text, [thinking_end_token, tool_calls_start_token])930        reasoning_content = content_delta931        assert stop_token == thinking_end_token, "Invalid thinking format: missing </think>"932 933    index, content_delta, stop_token = _read_until_stop(index, text, [eos_token, tool_calls_start_token])934    summary_content = content_delta935    if stop_token == tool_calls_start_token:936        is_tool_calling = True937    else:938        assert stop_token == eos_token, "Invalid format: missing EOS token"939 940    if is_tool_calling:941        index, stop_token, tool_calls = parse_tool_calls(index, text)942 943        index, tool_ends_text, stop_token = _read_until_stop(index, text, [eos_token])944        assert not tool_ends_text, "Unexpected content after tool calls"945 946    assert len(text) == index and stop_token in [eos_token, None], "Unexpected content at end"947 948    for sp_token in [bos_token, eos_token, thinking_start_token, thinking_end_token, dsml_token]:949        assert sp_token not in summary_content and sp_token not in reasoning_content, \950            f"Unexpected special token '{sp_token}' in content"951 952    return {953        "role": "assistant",954        "content": summary_content,955        "reasoning_content": reasoning_content,956        "tool_calls": tool_calls_to_openai_format(tool_calls)957    }958