CoolFace
Modelpublic

abacusai/Smaug-Agentic

sourceHugging Faceotherupdated 15d agoView on Hugging Face
25likes200downloads
encoding_k3.py648 linesDownload Raw Back to root
1"""Kimi K3 XTML encoding helpers.2 3This module keeps chat rendering in Python.4Callers that need token IDs should consume ``EncodeSegment`` objects directly:5structural markers may be encoded as tiktoken special tokens, while user/tool6text and attribute values are encoded as ordinary text.7"""8 9from __future__ import annotations10 11import json12from dataclasses import dataclass13from typing import Any, Iterable, Optional14 15OPEN_TOKEN = "<|open|>"16CLOSE_TOKEN = "<|close|>"17SEP_TOKEN = "<|sep|>"18END_OF_MSG_TOKEN = "<|end_of_msg|>"19IMAGE_PLACEHOLDER = "<|kimi_image_placeholder|>"20 21_VALID_THINKING_EFFORTS = {"low", "high", "max"}22 23 24@dataclass(frozen=True)25class EncodeSegment:26    text: str27    allow_special: bool = False28 29 30class _ImagePromptState:31    def __init__(self, image_prompts: Optional[list[str]] = None):32        self.image_prompts = image_prompts33        self.index = 034 35    def next_prompt(self) -> str:36        if self.image_prompts is None:37            return IMAGE_PLACEHOLDER38        if self.index >= len(self.image_prompts):39            raise ValueError("More image placeholders than image prompts.")40        prompt = self.image_prompts[self.index]41        self.index += 142        return prompt43 44    def assert_consumed(self) -> None:45        if self.image_prompts is None:46            return47        if self.index != len(self.image_prompts):48            raise ValueError(49                f"image prompt count {len(self.image_prompts)} != "50                f"consumed placeholder count {self.index}"51            )52 53 54def _segment(text: Any, *, allow_special: bool = False) -> list[EncodeSegment]:55    text = str(text)56    if not text:57        return []58    return [EncodeSegment(text, allow_special=allow_special)]59 60 61def _control(text: str) -> list[EncodeSegment]:62    return _segment(text, allow_special=True)63 64 65def _text(text: Any) -> list[EncodeSegment]:66    return _segment(text, allow_special=False)67 68 69def _append_text(70    segments: list[EncodeSegment],71    text: Any,72    image_state: _ImagePromptState,73) -> None:74    text = str(text)75    if text == "":76        return77    if image_state.image_prompts is None or IMAGE_PLACEHOLDER not in text:78        segments.extend(_text(text))79        return80 81    parts = text.split(IMAGE_PLACEHOLDER)82    for i, part in enumerate(parts):83        segments.extend(_text(part))84        if i < len(parts) - 1:85            segments.extend(_segment(image_state.next_prompt(),86                                     allow_special=True))87 88 89def _escape_attr_value(value: Any) -> str:90    return str(value).replace("&", "&amp;").replace('"', "&quot;")91 92 93def _attr(key: str, value: Any) -> list[EncodeSegment]:94    return (95        _text(f" {key}")96        + _text('="')97        + _text(_escape_attr_value(value))98        + _text('"')99    )100 101 102def _open_tag(tag: str, attrs: Iterable[tuple[str, Any]] = ()) -> list[EncodeSegment]:103    segments: list[EncodeSegment] = []104    segments.extend(_control(OPEN_TOKEN))105    segments.extend(_text(tag))106    for key, value in attrs:107        segments.extend(_attr(key, value))108    segments.extend(_control(SEP_TOKEN))109    return segments110 111def _close_tag(tag: str) -> list[EncodeSegment]:112    segments: list[EncodeSegment] = []113    segments.extend(_control(CLOSE_TOKEN))114    segments.extend(_text(tag))115    segments.extend(_control(SEP_TOKEN))116    return segments117 118 119def _end_of_msg() -> list[EncodeSegment]:120    return _control(END_OF_MSG_TOKEN)121 122 123def _json_compact(value: Any) -> str:124    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))125 126 127def _is_mapping(value: Any) -> bool:128    return isinstance(value, dict)129 130 131def _xtml_type(value: Any) -> str:132    if isinstance(value, bool):133        return "boolean"134    if value is None:135        return "null"136    if isinstance(value, (int, float)) and not isinstance(value, bool):137        return "number"138    if isinstance(value, str):139        return "string"140    if _is_mapping(value):141        return "object"142    return "array"143 144 145def _xtml_value(value: Any) -> str:146    if isinstance(value, str):147        return value148    return json.dumps(value, ensure_ascii=False)149 150 151def _get_value(obj: Any, key: str, default: Any = None) -> Any:152    if isinstance(obj, dict):153        return obj.get(key, default)154    return getattr(obj, key, default)155 156 157def extract_response_schema(response_format: Any) -> Any:158    if response_format is None:159        return None160 161    json_schema = _get_value(response_format, "json_schema")162    if json_schema is None:163        return None164 165    if isinstance(json_schema, dict):166        return json_schema.get(167            "schema",168            json_schema.get("json_schema", json_schema),169        )170 171    schema = _get_value(json_schema, "schema")172    if schema is not None:173        return schema174 175    schema = _get_value(json_schema, "json_schema")176    if schema is not None:177        return schema178 179    return json_schema180 181 182def deep_sort_dict(obj: Any) -> Any:183    if isinstance(obj, dict):184        return {k: deep_sort_dict(v) for k, v in sorted(obj.items())}185    if isinstance(obj, list):186        return [deep_sort_dict(item) for item in obj]187    return obj188 189 190def normalize_tool_arguments(arguments: Any) -> tuple[dict[str, Any], Optional[str]]:191    if arguments is None:192        return {}, None193    if isinstance(arguments, dict):194        return arguments, None195    if isinstance(arguments, str):196        if not arguments.strip():197            return {}, None198        try:199            parsed = json.loads(arguments)200        except json.JSONDecodeError:201            return {}, arguments202        if not isinstance(parsed, dict):203            raise ValueError("Kimi K3 tool call arguments must be a JSON object.")204        return parsed, None205    raise TypeError(206        "Kimi K3 tool call arguments must be a dict or a JSON object string."207    )208 209 210def normalize_message(message: Any) -> Any:211    if not isinstance(message, dict):212        return message213 214    normalized = dict(message)215 216    tools = normalized.get("tools")217    if tools is not None:218        normalized["tools"] = deep_sort_dict(tools)219 220    tool_calls = normalized.get("tool_calls")221    if not tool_calls:222        return normalized223 224    normalized_calls = []225    for tool_call in tool_calls:226        if not isinstance(tool_call, dict):227            normalized_calls.append(tool_call)228            continue229 230        tc = dict(tool_call)231        function = tc.get("function")232        if isinstance(function, dict):233            fn = dict(function)234            arguments, json_block = normalize_tool_arguments(fn.get("arguments"))235            fn["arguments"] = arguments236            if json_block is None:237                fn.pop("_xtml_json_block", None)238            else:239                fn["_xtml_json_block"] = json_block240            tc["function"] = fn241        else:242            arguments, json_block = normalize_tool_arguments(tc.get("arguments"))243            tc["arguments"] = arguments244            if json_block is None:245                tc.pop("_xtml_json_block", None)246            else:247                tc["_xtml_json_block"] = json_block248        normalized_calls.append(tc)249 250    normalized["tool_calls"] = normalized_calls251    return normalized252 253 254def normalize_conversation(conversation: Any) -> Any:255    if not isinstance(conversation, list):256        return conversation257 258    def normalize_messages(messages: list[Any]) -> list[Any]:259        return [normalize_message(message) for message in messages]260 261    if conversation and isinstance(conversation[0], list):262        return [normalize_messages(messages) for messages in conversation]263    return normalize_messages(conversation)264 265 266def _tool_call_id_index(tool_calls: Any) -> dict:267    """Map assistant ``tool_calls[].id`` to ``(1-based position, function name)``.268 269    The position mirrors the chat template's enumeration over ``tool_calls``270    (every entry advances the position, even an id-less one). Duplicate ids keep271    their first occurrence.272    """273    index: dict = {}274    if not isinstance(tool_calls, list):275        return index276    for position, tool_call in enumerate(tool_calls, start=1):277        if not isinstance(tool_call, dict):278            continue279        call_id = tool_call.get("id")280        if call_id is None:281            continue282        key = str(call_id)283        if key in index:284            continue285        function = tool_call.get("function")286        name = (287            function.get("name") if isinstance(function, dict) else tool_call.get("name")288        )289        index[key] = (position, name)290    return index291 292 293def normalize_xtml_tool_result_messages(messages: list[Any]) -> list[Any]:294    """Re-sort K3 XTML tool results into assistant ``tool_calls`` order.295 296    Serving frameworks generally deliver tool results already in call order. A297    direct Transformers caller, however, may pass OpenAI-style tool messages in any298    order, so each run of consecutive tool messages is matched against the most299    recent preceding assistant ``tool_calls`` by opaque ``tool_call_id`` ==300    ``tool_calls[].id`` (K3 drops the ``func:index`` format requirement) and301    sorted by the matched 1-based position. The matched call is authoritative,302    so each matched message's ``tool`` is set to that call's function name --303    this keeps an explicit (and possibly stale) ``tool``/``name`` from drifting304    out of sync with the reordered position. ``index`` is still derived from the305    rendered position by the chat template. A run that cannot be fully matched is306    left untouched. Re-running is idempotent.307 308    This function is side-effect free: matched tool messages are shallow-copied309    before their ``tool``/``name`` is rewritten, and every other message is310    appended to the output as-is. The input list and its message objects are311    never mutated.312    """313    if not isinstance(messages, list):314        return messages315 316    output: list[Any] = []317    current_index: dict = {}318    i = 0319    n = len(messages)320 321    while i < n:322        message = messages[i]323 324        if isinstance(message, dict) and message.get("role") == "assistant":325            tool_calls = message.get("tool_calls")326            current_index = _tool_call_id_index(tool_calls) if tool_calls else {}327            output.append(message)328            i += 1329            continue330 331        if not isinstance(message, dict) or message.get("role") != "tool":332            output.append(message)333            i += 1334            continue335 336        run: list[tuple] = []  # (position, original_offset, message, name)337        unresolved = False338        offset = 0339        while (340            i < n and isinstance(messages[i], dict) and messages[i].get("role") == "tool"341        ):342            tool_message = messages[i]343            call_id = tool_message.get("tool_call_id", tool_message.get("id"))344            matched = current_index.get(str(call_id)) if call_id is not None else None345            if matched is None:346                unresolved = True347                run.append((None, offset, tool_message, None))348            else:349                position, name = matched350                run.append((position, offset, tool_message, name))351            offset += 1352            i += 1353 354        if unresolved:355            output.extend(item[2] for item in run)356        else:357            run.sort(key=lambda item: (item[0], item[1]))358            for _, _, tool_message, name in run:359                if name is None:360                    output.append(tool_message)361                    continue362                # The id-matched call is authoritative: align tool (and any363                # explicit name) so the rendered XTML tool attribute cannot364                # disagree with the reordered position. Copy first so the365                # caller's message object is never mutated.366                resolved = dict(tool_message)367                resolved["tool"] = name368                if "name" in resolved:369                    resolved["name"] = name370                output.append(resolved)371 372    return output373 374 375def is_batched_conversation(conversation: Any) -> bool:376    return (377        isinstance(conversation, list)378        and bool(conversation)379        and isinstance(conversation[0], list)380    )381 382 383def _render_content_segments(384    content: Any,385    image_state: _ImagePromptState,386) -> list[EncodeSegment]:387    segments: list[EncodeSegment] = []388    if isinstance(content, str):389        _append_text(segments, content, image_state)390    elif content is not None:391        for part in content:392            if part["type"] in ["image", "image_url"]:393                segments.extend(394                    _segment(image_state.next_prompt(), allow_special=True))395            else:396                _append_text(segments, part["text"], image_state)397    return segments398 399 400def _internal_system_message(message_type: str, body: str) -> list[EncodeSegment]:401    segments: list[EncodeSegment] = []402    segments.extend(_open_tag("message", [("role", "system"), ("type", message_type)]))403    segments.extend(_text(body.strip()))404    segments.extend(_close_tag("message"))405    segments.extend(_end_of_msg())406    return segments407 408 409def _render_assistant_segments(410    message: dict[str, Any],411    image_state: _ImagePromptState,412    thinking: bool = True,413) -> list[EncodeSegment]:414    segments: list[EncodeSegment] = []415    # The <think> channel is structural: in thinking mode every assistant416    # message carries the open/close tags even when there is no reasoning417    # content to fill in. In non-thinking mode the channel is dropped418    # entirely.419    if thinking:420        reasoning_content = message.get("reasoning_content") or message.get(421            "reasoning"422        )423        segments.extend(_open_tag("think"))424        if reasoning_content is not None and str(reasoning_content).strip():425            _append_text(segments, reasoning_content, image_state)426        segments.extend(_close_tag("think"))427 428    segments.extend(_open_tag("response"))429    segments.extend(_render_content_segments(message.get("content"), image_state))430    segments.extend(_close_tag("response"))431 432    tool_calls = message.get("tool_calls")433    if tool_calls:434        segments.extend(_open_tag("tools"))435        for index, tool_call in enumerate(tool_calls, start=1):436            fn = tool_call.get("function", tool_call)437            segments.extend(438                _open_tag("call", [("tool", fn["name"]), ("index", index)])439            )440            args = fn.get("arguments", {})441            json_block = fn.get("_xtml_json_block")442            if json_block is not None:443                segments.extend(_open_tag("json", [("type", "object")]))444                _append_text(segments, json_block, image_state)445                segments.extend(_close_tag("json"))446            elif _is_mapping(args):447                for key, value in args.items():448                    segments.extend(449                        _open_tag(450                            "argument",451                            [("key", key), ("type", _xtml_type(value))],452                        )453                    )454                    _append_text(segments, _xtml_value(value), image_state)455                    segments.extend(_close_tag("argument"))456            segments.extend(_close_tag("call"))457        segments.extend(_close_tag("tools"))458 459    return segments460 461 462def _render_tool_declare(tools: Any, *, dynamic: bool = False) -> list[EncodeSegment]:463    if dynamic:464        body = (465            "## New Tools Available\n"466            "The system dynamically extends the toolset via lazy-loading.\n"467            "You have access to all existing and extended tools.\n"468            "Here are the specs for the extended tools.\n\n"469            "```json\n"470            f"{_json_compact(tools)}\n"471            "```"472        )473    else:474        body = (475            "# Tools\n"476            "Here are the available tools, described in JSONSchema.\n\n"477            "```json\n"478            f"{_json_compact(tools)}\n"479            "```"480        )481    segments: list[EncodeSegment] = []482    segments.extend(_open_tag("message", [("role", "system"), ("type", "tool-declare")]))483    segments.extend(_text(body))484    segments.extend(_close_tag("message"))485    segments.extend(_end_of_msg())486    return segments487 488 489def build_chat_segments(490    messages: list[Any],491    tools: Optional[list[dict]] = None,492    *,493    add_generation_prompt: bool = True,494    thinking: bool = True,495    image_prompts: Optional[list[str]] = None,496    **kwargs: Any,497) -> list[EncodeSegment]:498    # Re-sort tool results by tool_call_id at the lowest layer so every caller499    # (processor or direct tokenizer) gets correctly ordered XTML. The helper is500    # side-effect free, so the caller's message objects are left untouched.501    messages = normalize_xtml_tool_result_messages(messages)502    messages = normalize_conversation(messages)503    tools = deep_sort_dict(tools)504 505    kwargs = dict(kwargs)506    response_format = kwargs.get("response_format")507    if "response_schema" not in kwargs:508        response_schema = extract_response_schema(response_format)509        if response_schema is not None:510            kwargs["response_schema"] = response_schema511    if kwargs.get("response_schema") is not None:512        kwargs["response_schema"] = deep_sort_dict(kwargs["response_schema"])513 514    image_state = _ImagePromptState(image_prompts)515    segments: list[EncodeSegment] = []516 517    tool_calls = None518    tool_index = 0519 520    if tools:521        segments.extend(_render_tool_declare(tools))522 523    thinking_effort = kwargs.get("thinking_effort")524    if thinking and thinking_effort is not None:525        assert thinking_effort in _VALID_THINKING_EFFORTS, (526            f"Unsupported thinking_effort={thinking_effort!r}; "527            f"supported values are {sorted(_VALID_THINKING_EFFORTS)}."528        )529    if thinking and thinking_effort in _VALID_THINKING_EFFORTS:530        segments.extend(531            _internal_system_message(532                "thinking-effort",533                "`thinking_effort` guides on how much to think in your "534                "thinking channel (not including the response channel), "535                "supported values include `low`, `medium`, `high`, and `max`.\n"536                f"Now the system is invoked with `thinking_effort={thinking_effort}`.",537            )538        )539 540    for message_index, message in enumerate(messages):541        if not isinstance(message, dict):542            continue543 544        role = message["role"]545        if role == "user":546            attrs = [("role", "user")]547            if message.get("name"):548                attrs.append(("name", message["name"]))549            segments.extend(_open_tag("message", attrs))550            segments.extend(_render_content_segments(message.get("content"), image_state))551            segments.extend(_close_tag("message"))552            segments.extend(_end_of_msg())553        elif role == "system" and message.get("tools"):554            segments.extend(_render_tool_declare(message["tools"], dynamic=True))555        elif role == "system":556            attrs = [("role", "system")]557            if message.get("name"):558                attrs.append(("name", message["name"]))559            segments.extend(_open_tag("message", attrs))560            segments.extend(_render_content_segments(message.get("content"), image_state))561            segments.extend(_close_tag("message"))562            segments.extend(_end_of_msg())563        elif role == "tool":564            tool_index += 1565            tool_name = message.get("tool", message.get("name"))566            if (567                tool_name is None568                and tool_calls is not None569                and tool_index <= len(tool_calls)570            ):571                tc = tool_calls[tool_index - 1]572                fn = tc.get("function", tc)573                tool_name = fn["name"]574            if tool_name is None:575                raise ValueError(576                    "Kimi K3 tool messages need a resolvable tool name: "577                    "carry `tool`/`name`, or match a preceding assistant "578                    "tool_call by order."579                )580            segments.extend(581                _open_tag(582                    "message",583                    [("role", "tool"), ("tool", tool_name), ("index", tool_index)],584                )585            )586            segments.extend(_render_content_segments(message.get("content"), image_state))587            segments.extend(_close_tag("message"))588            segments.extend(_end_of_msg())589        elif role == "assistant":590            tool_calls = message.get("tool_calls")591            tool_index = 0592            attrs = [("role", "assistant")]593            if message.get("name"):594                attrs.append(("name", message["name"]))595            segments.extend(_open_tag("message", attrs))596            segments.extend(_render_assistant_segments(message, image_state, thinking))597            segments.extend(_close_tag("message"))598            segments.extend(_end_of_msg())599 600    tool_choice = kwargs.get("tool_choice")601    if tool_choice == "required":602        segments.extend(603            _internal_system_message(604                "tool-choice",605                "The system is invoked with `tool_choice=required`.\n"606                "You MUST call tools in the next message.",607            )608        )609    elif tool_choice == "none":610        segments.extend(611            _internal_system_message(612                "tool-choice",613                "The system is invoked with `tool_choice=none`.\n"614                "You MUST NOT call any tools in the next message.",615            )616        )617 618    rf = kwargs.get("response_format")619    rf_type = _get_value(rf, "type", rf) if isinstance(rf, dict) else rf620    if rf_type == "json_object":621        segments.extend(622            _internal_system_message(623                "response-format",624                "The system is invoked with `response_format=json_object`.\n"625                "Your response must be raw JSON data without markdown code "626                "blocks (```json) or any additional formatting.",627            )628        )629    elif rf_type == "json_schema":630        schema = _json_compact(kwargs.get("response_schema"))631        segments.extend(632            _internal_system_message(633                "response-format",634                "The system is invoked with `response_format=json_schema`.\n"635                "Your response must be raw JSON data without markdown code "636                "blocks (```json) or any additional formatting.\n"637                "The JSON data must match the following schema:\n"638                f"```json\n{schema}\n```",639            )640        )641 642    if add_generation_prompt:643        segments.extend(_open_tag("message", [("role", "assistant")]))644        segments.extend(_open_tag("think" if thinking else "response"))645 646    image_state.assert_consumed()647    return segments648