Coverage for src/lilbee/providers/fleet/normalize.py: 100%
73 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Reshape an OpenAI tool conversation into strict user/assistant alternation.
3Some GGUF chat templates (Mistral-Nemo, Cohere command-r) reject a standard
4OpenAI tool exchange: they require plain user/assistant turns to alternate and
5raise a Jinja exception on the ``tool`` role or on two same-role turns in a row.
6:func:`to_alternating` rewrites the conversation into the shape those templates
7accept. The fleet client learns which models need this by probing the live
8template once, then applies it proactively before every such request.
9"""
11from __future__ import annotations
13import json
14from collections.abc import Mapping, Sequence
15from typing import Any, Final, Literal, TypedDict
17# Roles this reshaper understands; an unrecognized role falls through to a plain
18# user turn, so the Literal documents the set without rejecting foreign input.
19ChatRole = Literal["system", "user", "assistant", "tool"]
20_SYSTEM_ROLE: Final = "system"
21_USER_ROLE: Final = "user"
22_ASSISTANT_ROLE: Final = "assistant"
23_TOOL_ROLE: Final = "tool"
24# A tool result carried as a user turn is labelled so the model reads it as a
25# result rather than a fresh user instruction.
26_TOOL_RESULT_PREFIX = "Tool result:"
27# An assistant turn whose only payload was tool_calls becomes a short text note
28# so the turn is non-empty (the templates reject empty content).
29_TOOL_CALL_NOTE_PREFIX = "Calling tool"
32class ChatToolCallFunction(TypedDict, total=False):
33 """The ``function`` payload of an OpenAI tool call (wire shape)."""
35 name: str
36 arguments: str
39class ChatToolCall(TypedDict, total=False):
40 """One entry of an assistant message's ``tool_calls`` (wire shape)."""
42 id: str
43 type: str
44 function: ChatToolCallFunction
47class ChatMessage(TypedDict, total=False):
48 """One OpenAI chat message in transit. Keys are partial: which are present
49 depends on the role (a tool result carries ``tool_call_id``, an assistant
50 tool call carries ``tool_calls``)."""
52 role: ChatRole
53 content: Any
54 tool_calls: list[ChatToolCall]
55 tool_call_id: str
58def _content_text(content: Any) -> str:
59 """Flatten a message ``content`` (string or OpenAI multipart list) to text."""
60 if isinstance(content, str):
61 return content
62 if isinstance(content, list):
63 parts = [
64 str(part.get("text", ""))
65 for part in content
66 if isinstance(part, dict) and part.get("type") == "text"
67 ]
68 return "\n".join(p for p in parts if p)
69 if content is None:
70 return ""
71 return str(content)
74def _one_tool_call_note(call: Any) -> str:
75 """Render one tool call as ``Calling name(args)``; empty when malformed."""
76 fn = call.get("function") if isinstance(call, dict) else None
77 if not isinstance(fn, dict):
78 return ""
79 name = fn.get("name")
80 if not isinstance(name, str) or not name:
81 return ""
82 arguments = fn.get("arguments")
83 rendered_args = arguments if isinstance(arguments, str) else json.dumps(arguments)
84 return f"{_TOOL_CALL_NOTE_PREFIX} {name}({rendered_args})"
87def _tool_calls_note(tool_calls: Any) -> str:
88 """Render an assistant message's ``tool_calls`` as a short text note."""
89 if not isinstance(tool_calls, list):
90 return ""
91 return "\n".join(note for call in tool_calls if (note := _one_tool_call_note(call)))
94def _assistant_text(message: Mapping[str, Any]) -> str:
95 """Assistant turn text: its content plus a note for any tool_calls it made."""
96 pieces = [_content_text(message.get("content")), _tool_calls_note(message.get("tool_calls"))]
97 return "\n".join(piece for piece in pieces if piece)
100def _to_turn(message: Mapping[str, Any]) -> tuple[ChatRole, str]:
101 """Map one OpenAI message to a ``(user|assistant, text)`` pair.
103 An assistant turn keeps its content and gains a note for any tool calls; a
104 ``tool`` result becomes a labelled user turn; everything else, including a
105 stray non-leading ``system`` message, becomes a plain user turn (so it can't
106 re-open a system block or break alternation mid-conversation).
107 """
108 role = message.get("role")
109 if role == _ASSISTANT_ROLE:
110 return _ASSISTANT_ROLE, _assistant_text(message)
111 if role == _TOOL_ROLE:
112 result = _content_text(message.get("content"))
113 return _USER_ROLE, f"{_TOOL_RESULT_PREFIX} {result}".strip()
114 return _USER_ROLE, _content_text(message.get("content"))
117def _append_or_merge(turns: list[ChatMessage], turn: ChatMessage) -> None:
118 """Append a turn, or fold it into the previous turn when the role repeats."""
119 if turns and turns[-1]["role"] == turn["role"]:
120 turns[-1]["content"] = f"{turns[-1]['content']}\n{turn['content']}"
121 else:
122 turns.append(turn)
125def to_alternating(messages: Sequence[Mapping[str, Any]]) -> list[ChatMessage]:
126 """Rewrite an OpenAI tool conversation into strict user/assistant alternation.
128 Keeps any leading system messages verbatim, maps each remaining message to a
129 plain user or assistant turn (``tool`` results become labelled user turns;
130 assistant tool calls become a text note), then merges consecutive same-role
131 turns so the result alternates user/assistant after the system block. Every
132 emitted turn has non-empty content, so a strict-alternation template accepts
133 it.
134 """
135 leading_system: list[ChatMessage] = []
136 index = 0
137 while index < len(messages) and messages[index].get("role") == _SYSTEM_ROLE:
138 leading_system.append(
139 {"role": _SYSTEM_ROLE, "content": _content_text(messages[index].get("content"))}
140 )
141 index += 1
143 turns: list[ChatMessage] = []
144 for message in messages[index:]:
145 role, text = _to_turn(message)
146 if text:
147 _append_or_merge(turns, {"role": role, "content": text})
148 return leading_system + turns