"""Tests for llms._responses_to_chat — bidirectional translation.""" from typing import Any import pytest from omnigent.llms._responses_to_chat import ( _translate_block, _translate_content, chat_response_to_response, chat_stream_to_response_events, responses_input_to_chat_messages, ) from omnigent.llms.types import ( FunctionCallOutput, MessageOutput, Response, ResponseCompletedEvent, ResponseReasoningStartedEvent, ResponseReasoningTextDeltaEvent, ResponseTextDeltaEvent, Usage, ) # ── Input direction: Responses API -> Chat Completions ──── def test_instructions_become_system_message() -> None: messages = responses_input_to_chat_messages([], "Be helpful.") assert messages == [{"role": "system", "content": "Be helpful."}] def test_no_instructions_no_system_message() -> None: items = [{"role": "user", "content": "Hi"}] messages = responses_input_to_chat_messages(items, None) assert messages == [{"role": "user", "content": "Hi"}] def test_user_and_assistant_messages_passthrough() -> None: items = [ {"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there"}, ] messages = responses_input_to_chat_messages(items, None) assert messages == [ {"role": "user", "content": "Hello"}, {"role": "assistant", "content": "Hi there"}, ] def test_function_call_items_grouped_into_assistant_message() -> None: items = [ {"role": "user", "content": "What's the weather?"}, { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": '{"city": "London"}', }, { "type": "function_call", "call_id": "call_2", "name": "get_time", "arguments": '{"tz": "UTC"}', }, ] messages = responses_input_to_chat_messages(items, None) assert len(messages) == 2 assert messages[0] == {"role": "user", "content": "What's the weather?"} assert messages[1]["role"] == "assistant" assert messages[1]["content"] is None assert len(messages[1]["tool_calls"]) == 2 assert messages[1]["tool_calls"][0] == { "id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "London"}'}, } def test_function_call_output_becomes_tool_message() -> None: items = [ { "type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{}", }, { "type": "function_call_output", "call_id": "call_1", "output": "Sunny, 22C", }, ] messages = responses_input_to_chat_messages(items, None) # First: assistant with tool_calls, second: tool message assert len(messages) == 2 assert messages[1] == { "role": "tool", "tool_call_id": "call_1", "content": "Sunny, 22C", } def test_full_conversation_round_trip() -> None: """ Test a realistic conversation: user -> assistant tool call -> tool output -> assistant follow-up. """ items = [ {"role": "user", "content": "Weather in London?"}, { "type": "function_call", "call_id": "c1", "name": "get_weather", "arguments": '{"city": "London"}', }, { "type": "function_call_output", "call_id": "c1", "output": "Rainy, 15C", }, {"role": "assistant", "content": "It's rainy and 15C in London."}, ] messages = responses_input_to_chat_messages(items, "Be concise.") assert len(messages) == 5 assert messages[0] == {"role": "system", "content": "Be concise."} assert messages[1] == {"role": "user", "content": "Weather in London?"} assert messages[2]["role"] == "assistant" assert messages[2]["tool_calls"][0]["id"] == "c1" assert messages[3] == { "role": "tool", "tool_call_id": "c1", "content": "Rainy, 15C", } assert messages[4] == { "role": "assistant", "content": "It's rainy and 15C in London.", } # ── Content block translation (multimodal) ───────────────── def test_text_only_content_blocks_collapse_to_string() -> None: """ When all content blocks are text, _translate_content collapses them to a plain string — avoids sending content arrays to providers that only support string content for text-only messages. """ content = [ {"type": "input_text", "text": "Hello"}, {"type": "input_text", "text": "World"}, ] result = _translate_content(content) # Collapsed to a single joined string, not a list. assert result == "Hello\nWorld" def test_single_text_block_collapses_to_string() -> None: """ Even a single text block collapses to a plain string — this is the common case and ensures backward compatibility with providers that expect string content. """ content = [{"type": "input_text", "text": "Hello"}] assert _translate_content(content) == "Hello" def test_string_content_passes_through() -> None: """ Plain string content (legacy path) passes through unchanged. """ assert _translate_content("Hello") == "Hello" def test_none_content_passes_through() -> None: """ None content (e.g. assistant messages with only tool_calls) passes through unchanged. """ assert _translate_content(None) is None def test_image_block_translated_to_chat_format() -> None: """ input_image with image_url translates to Chat Completions image_url format with nested url field. """ block = { "type": "input_image", "image_url": "data:image/png;base64,abc123", "detail": "high", } result = _translate_block(block) assert result == { "type": "image_url", "image_url": { "url": "data:image/png;base64,abc123", "detail": "high", }, } def test_image_block_without_detail() -> None: """ input_image without detail field omits detail from output — we don't invent a default. """ block = { "type": "input_image", "image_url": "data:image/png;base64,abc123", } result = _translate_block(block) assert result == { "type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}, } # detail must NOT be present — no invented default. assert "detail" not in result["image_url"] def test_input_file_block_passes_through() -> None: """ input_file blocks pass through as-is — provider adapters (Phase 3) are responsible for translating to native format. """ block = { "type": "input_file", "file_data": "data:application/pdf;base64,JVBERi0xLjQK", "filename": "report.pdf", } result = _translate_block(block) # Passed through unchanged — same object. assert result is block def test_unknown_block_type_passes_through() -> None: """ Unrecognized block types (e.g. input_audio) pass through as-is for forward compatibility. """ block = {"type": "input_audio", "file_data": "base64data"} result = _translate_block(block) assert result is block def test_mixed_text_and_image_stays_as_list() -> None: """ When content contains both text and non-text blocks, the result is a list (not collapsed to string) so multimodal content reaches the provider. """ content = [ {"type": "input_text", "text": "What's in this image?"}, { "type": "input_image", "image_url": "data:image/png;base64,abc", "detail": "auto", }, ] result = _translate_content(content) assert isinstance(result, list) # Two translated blocks: text + image_url. assert len(result) == 2 assert result[0] == {"type": "text", "text": "What's in this image?"} assert result[1] == { "type": "image_url", "image_url": {"url": "data:image/png;base64,abc", "detail": "auto"}, } def test_multimodal_content_in_full_message() -> None: """ End-to-end: a user message with content blocks flows through responses_input_to_chat_messages with correct translation. """ items = [ { "role": "user", "content": [ {"type": "input_text", "text": "Describe this"}, { "type": "input_image", "image_url": "data:image/png;base64,img", }, ], }, ] messages = responses_input_to_chat_messages(items, None) # One user message with translated content blocks. assert len(messages) == 1 content = messages[0]["content"] assert isinstance(content, list) assert content[0] == {"type": "text", "text": "Describe this"} assert content[1] == { "type": "image_url", "image_url": {"url": "data:image/png;base64,img"}, } @pytest.mark.parametrize( ("block_type", "text_key"), [ pytest.param("input_text", "text", id="input_text"), pytest.param("output_text", "text", id="output_text"), pytest.param("text", "text", id="bare_text"), ], ) def test_all_text_block_types_translate( block_type: str, text_key: str, ) -> None: """ All recognized text block types (input_text, output_text, text) translate to Chat Completions ``{"type": "text", ...}`` format. """ block = {"type": block_type, text_key: "Hello"} result = _translate_block(block) assert result == {"type": "text", "text": "Hello"} # ── Output direction: Chat Completions -> Responses API ─── def test_chat_text_response_to_response() -> None: chat_dict = { "id": "chatcmpl-123", "model": "gpt-5.4", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "Hello!", "tool_calls": None, }, "finish_reason": "stop", } ], "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15, }, } resp = chat_response_to_response(chat_dict) assert isinstance(resp, Response) assert resp.model == "gpt-5.4" assert len(resp.output) == 1 assert isinstance(resp.output[0], MessageOutput) assert resp.output[0].content[0].text == "Hello!" assert resp.usage == Usage(input_tokens=10, output_tokens=5, total_tokens=15) def test_chat_tool_calls_to_response() -> None: chat_dict = { "id": "chatcmpl-456", "model": "gpt-5.4", "choices": [ { "index": 0, "message": { "role": "assistant", "content": None, "tool_calls": [ { "id": "call_abc", "type": "function", "function": { "name": "get_weather", "arguments": '{"city": "London"}', }, } ], }, "finish_reason": "tool_calls", } ], "usage": None, } resp = chat_response_to_response(chat_dict) assert len(resp.output) == 1 assert isinstance(resp.output[0], FunctionCallOutput) assert resp.output[0].call_id == "call_abc" assert resp.output[0].name == "get_weather" assert resp.output[0].arguments == '{"city": "London"}' def test_chat_mixed_text_and_tool_calls() -> None: chat_dict = { "model": "gpt-5.4", "choices": [ { "message": { "role": "assistant", "content": "Let me check.", "tool_calls": [ { "id": "call_1", "type": "function", "function": { "name": "search", "arguments": '{"q": "test"}', }, } ], }, "finish_reason": "tool_calls", } ], } resp = chat_response_to_response(chat_dict) assert len(resp.output) == 2 assert isinstance(resp.output[0], MessageOutput) assert isinstance(resp.output[1], FunctionCallOutput) # ── Streaming: Chat Completions chunks -> events ────────── async def _aiter( items: list[dict[str, Any]], ) -> Any: """ Wrap a list as an async iterator for testing. :param items: Items to yield. """ for item in items: yield item @pytest.mark.asyncio async def test_streaming_text_deltas() -> None: chunks = [ {"choices": [{"delta": {"content": "Hello"}, "finish_reason": None}]}, {"choices": [{"delta": {"content": " world"}, "finish_reason": None}]}, {"choices": [{"delta": {}, "finish_reason": "stop"}]}, ] events = [e async for e in chat_stream_to_response_events(_aiter(chunks), model="test")] # Two text deltas + one completed event assert len(events) == 3 assert isinstance(events[0], ResponseTextDeltaEvent) assert events[0].delta == "Hello" assert isinstance(events[1], ResponseTextDeltaEvent) assert events[1].delta == " world" assert isinstance(events[2], ResponseCompletedEvent) assert events[2].response.output[0].content[0].text == "Hello world" @pytest.mark.asyncio async def test_streaming_tool_calls() -> None: chunks = [ { "choices": [ { "delta": { "tool_calls": [ { "index": 0, "id": "call_1", "type": "function", "function": {"name": "get_weather"}, } ] }, "finish_reason": None, } ] }, { "choices": [ { "delta": { "tool_calls": [ { "index": 0, "function": {"arguments": '{"city":'}, } ] }, "finish_reason": None, } ] }, { "choices": [ { "delta": { "tool_calls": [ { "index": 0, "function": {"arguments": '"London"}'}, } ] }, "finish_reason": None, } ] }, {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}, ] events = [e async for e in chat_stream_to_response_events(_aiter(chunks), model="test")] # No text deltas, just the completed event completed = events[-1] assert isinstance(completed, ResponseCompletedEvent) fc = completed.response.output[0] assert isinstance(fc, FunctionCallOutput) assert fc.call_id == "call_1" assert fc.name == "get_weather" assert fc.arguments == '{"city":"London"}' @pytest.mark.asyncio async def test_streaming_with_usage() -> None: chunks = [ {"choices": [{"delta": {"content": "Hi"}, "finish_reason": None}]}, { "choices": [{"delta": {}, "finish_reason": "stop"}], "usage": { "prompt_tokens": 5, "completion_tokens": 1, "total_tokens": 6, }, }, ] events = [e async for e in chat_stream_to_response_events(_aiter(chunks), model="test")] completed = events[-1] assert isinstance(completed, ResponseCompletedEvent) assert completed.response.usage == Usage(input_tokens=5, output_tokens=1, total_tokens=6) # ── Kimi / list-content streaming ──────────────────────────────────────────── @pytest.mark.asyncio async def test_kimi_list_content_text_only() -> None: """ When ``delta.content`` is a list of text blocks (Kimi-style), text is extracted and emitted as ``ResponseTextDeltaEvent`` — no reasoning events. """ chunks = [ { "choices": [ { "delta": {"content": [{"type": "text", "text": "Hello"}]}, "finish_reason": None, } ] }, { "choices": [ { "delta": {"content": [{"type": "text", "text": " world"}]}, "finish_reason": None, } ] }, {"choices": [{"delta": {}, "finish_reason": "stop"}]}, ] events = [e async for e in chat_stream_to_response_events(_aiter(chunks), model="kimi")] text_events = [e for e in events if isinstance(e, ResponseTextDeltaEvent)] reasoning_events = [e for e in events if isinstance(e, ResponseReasoningTextDeltaEvent)] completed = events[-1] assert [e.delta for e in text_events] == ["Hello", " world"] assert reasoning_events == [] assert isinstance(completed, ResponseCompletedEvent) assert completed.response.output[0].content[0].text == "Hello world" @pytest.mark.asyncio async def test_kimi_reasoning_then_text() -> None: """ Kimi prefixes answers with reasoning blocks. Reasoning deltas become ``ResponseReasoningStartedEvent`` + ``ResponseReasoningTextDeltaEvent`` and are NOT included in the final message text. """ chunks = [ { "choices": [ { "delta": { "content": [ { "type": "reasoning", "summary": [{"type": "summary_text", "text": "Let me think..."}], } ] }, "finish_reason": None, } ] }, { "choices": [ { "delta": {"content": [{"type": "text", "text": "The answer is 42."}]}, "finish_reason": None, } ] }, {"choices": [{"delta": {}, "finish_reason": "stop"}]}, ] events = [e async for e in chat_stream_to_response_events(_aiter(chunks), model="kimi")] reasoning_started = [e for e in events if isinstance(e, ResponseReasoningStartedEvent)] reasoning_deltas = [e for e in events if isinstance(e, ResponseReasoningTextDeltaEvent)] text_deltas = [e for e in events if isinstance(e, ResponseTextDeltaEvent)] completed = events[-1] # One reasoning.started sentinel, one reasoning delta with the thinking text. assert len(reasoning_started) == 1 assert len(reasoning_deltas) == 1 assert reasoning_deltas[0].delta == "Let me think..." # Reasoning is hidden from the main answer text. assert len(text_deltas) == 1 assert text_deltas[0].delta == "The answer is 42." assert isinstance(completed, ResponseCompletedEvent) # Only the answer is in the response output — reasoning is NOT included. assert completed.response.output[0].content[0].text == "The answer is 42." @pytest.mark.asyncio async def test_kimi_reasoning_started_emitted_once_per_run() -> None: """ ``ResponseReasoningStartedEvent`` is emitted only once even when multiple consecutive reasoning chunks arrive. """ chunks = [ { "choices": [ { "delta": { "content": [ { "type": "reasoning", "summary": [{"type": "summary_text", "text": "part 1"}], } ] }, "finish_reason": None, } ] }, { "choices": [ { "delta": { "content": [ { "type": "reasoning", "summary": [{"type": "summary_text", "text": " part 2"}], } ] }, "finish_reason": None, } ] }, {"choices": [{"delta": {}, "finish_reason": "stop"}]}, ] events = [e async for e in chat_stream_to_response_events(_aiter(chunks), model="kimi")] reasoning_started = [e for e in events if isinstance(e, ResponseReasoningStartedEvent)] reasoning_deltas = [e for e in events if isinstance(e, ResponseReasoningTextDeltaEvent)] assert len(reasoning_started) == 1 assert [e.delta for e in reasoning_deltas] == ["part 1", " part 2"] @pytest.mark.asyncio async def test_grok_top_level_reasoning_content_streams() -> None: """ xAI Grok / DeepSeek emit chain-of-thought as a top-level ``delta.reasoning_content`` string (with ``content`` null during the thinking phase), not as typed blocks nested inside ``content``. It must surface as ``ResponseReasoningStartedEvent`` + ``ResponseReasoningTextDeltaEvent`` and stay out of the final answer text. """ chunks = [ {"choices": [{"delta": {"reasoning_content": "Let me think..."}, "finish_reason": None}]}, {"choices": [{"delta": {"reasoning_content": " still thinking"}, "finish_reason": None}]}, {"choices": [{"delta": {"content": "42"}, "finish_reason": None}]}, {"choices": [{"delta": {}, "finish_reason": "stop"}]}, ] events = [ e async for e in chat_stream_to_response_events(_aiter(chunks), model="xai/grok-4.3") ] reasoning_started = [e for e in events if isinstance(e, ResponseReasoningStartedEvent)] reasoning_deltas = [e for e in events if isinstance(e, ResponseReasoningTextDeltaEvent)] text_deltas = [e for e in events if isinstance(e, ResponseTextDeltaEvent)] completed = events[-1] # One reasoning.started sentinel; both reasoning deltas surfaced in order. assert len(reasoning_started) == 1 assert [e.delta for e in reasoning_deltas] == ["Let me think...", " still thinking"] # Reasoning is hidden from the main answer text. assert len(text_deltas) == 1 assert text_deltas[0].delta == "42" assert isinstance(completed, ResponseCompletedEvent) assert completed.response.output[0].content[0].text == "42" # ── _extract_delta_content unit tests ────────────────────────────── def test_extract_delta_content_plain_string() -> None: """Plain string content returns (text, empty_reasoning).""" from omnigent.llms._responses_to_chat import _extract_delta_content text, reasoning = _extract_delta_content("Hello") assert text == "Hello" assert reasoning == "" def test_extract_delta_content_non_string_non_list() -> None: """Non-string, non-list content returns empty strings.""" from omnigent.llms._responses_to_chat import _extract_delta_content text, reasoning = _extract_delta_content(42) # type: ignore[arg-type] assert text == "" assert reasoning == "" def test_extract_delta_content_list_with_text_blocks() -> None: """List of text blocks extracts text.""" from omnigent.llms._responses_to_chat import _extract_delta_content content = [{"type": "text", "text": "Hello"}, {"type": "text", "text": " world"}] text, reasoning = _extract_delta_content(content) assert text == "Hello world" assert reasoning == "" def test_extract_delta_content_list_with_output_text_blocks() -> None: """output_text blocks also count as text.""" from omnigent.llms._responses_to_chat import _extract_delta_content content = [{"type": "output_text", "text": "Hello"}] text, _reasoning = _extract_delta_content(content) assert text == "Hello" def test_extract_delta_content_list_with_reasoning_blocks() -> None: """Reasoning blocks extract summary text into reasoning output.""" from omnigent.llms._responses_to_chat import _extract_delta_content content = [ { "type": "reasoning", "summary": [{"type": "summary_text", "text": "thinking..."}], } ] text, reasoning = _extract_delta_content(content) assert text == "" assert reasoning == "thinking..." def test_extract_delta_content_list_with_bare_strings() -> None: """Bare strings in the list are treated as text.""" from omnigent.llms._responses_to_chat import _extract_delta_content content = ["Hello", " world"] text, _reasoning = _extract_delta_content(content) assert text == "Hello world" def test_extract_delta_content_list_skips_non_dict_non_string() -> None: """Non-dict, non-string items in the list are skipped.""" from omnigent.llms._responses_to_chat import _extract_delta_content content = [42, {"type": "text", "text": "ok"}] text, _reasoning = _extract_delta_content(content) assert text == "ok" def test_extract_delta_content_reasoning_without_summary() -> None: """Reasoning block without summary key yields no reasoning text.""" from omnigent.llms._responses_to_chat import _extract_delta_content content = [{"type": "reasoning"}] _text, reasoning = _extract_delta_content(content) assert reasoning == "" # ── _extract_usage unit tests ────────────────────────────────────── def test_extract_usage_returns_none_for_none() -> None: from omnigent.llms._responses_to_chat import _extract_usage assert _extract_usage(None) is None def test_extract_usage_returns_none_for_empty_dict() -> None: from omnigent.llms._responses_to_chat import _extract_usage assert _extract_usage({}) is None def test_extract_usage_maps_fields() -> None: from omnigent.llms._responses_to_chat import _extract_usage usage = _extract_usage({"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}) assert usage is not None assert usage.input_tokens == 10 assert usage.output_tokens == 5 assert usage.total_tokens == 15 # ── Streaming: usage-only final chunk ────────────────────────────── @pytest.mark.asyncio async def test_streaming_usage_only_chunk() -> None: """A trailing chunk with only usage (no choices) captures the usage.""" chunks = [ {"choices": [{"delta": {"content": "Hi"}, "finish_reason": None}]}, {"choices": [{"delta": {}, "finish_reason": "stop"}]}, { "usage": { "prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12, } }, ] events = [e async for e in chat_stream_to_response_events(_aiter(chunks), model="test")] completed = events[-1] assert isinstance(completed, ResponseCompletedEvent) assert completed.response.usage == Usage(input_tokens=10, output_tokens=2, total_tokens=12) # ── Trailing tool calls flushed ──────────────────────────────────── def test_trailing_function_calls_flushed() -> None: """Function call items at the end of input are flushed into assistant msg.""" items = [ { "type": "function_call", "call_id": "c1", "name": "fn", "arguments": "{}", }, ] messages = responses_input_to_chat_messages(items, None) assert len(messages) == 1 assert messages[0]["role"] == "assistant" assert len(messages[0]["tool_calls"]) == 1