551 lines
18 KiB
Python
551 lines
18 KiB
Python
"""Tests for llms.adapters.bedrock — translation logic."""
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from omnigent.llms.adapters.bedrock import (
|
|
_build_converse_kwargs,
|
|
_converse_to_chat,
|
|
_convert_tools,
|
|
_messages_to_converse,
|
|
_translate_part_to_converse,
|
|
)
|
|
|
|
# ── Request translation ──────────────────────────────────
|
|
|
|
|
|
def test_system_messages_extracted_as_system_prompts() -> None:
|
|
messages = [
|
|
{"role": "system", "content": "Be helpful."},
|
|
{"role": "user", "content": "Hi"},
|
|
]
|
|
converse_msgs, system_prompts = _messages_to_converse(messages)
|
|
assert system_prompts == [{"text": "Be helpful."}]
|
|
assert len(converse_msgs) == 1
|
|
assert converse_msgs[0]["role"] == "user"
|
|
|
|
|
|
def test_user_message_converted_to_text_block() -> None:
|
|
messages = [{"role": "user", "content": "Hello"}]
|
|
converse_msgs, _ = _messages_to_converse(messages)
|
|
assert converse_msgs[0] == {
|
|
"role": "user",
|
|
"content": [{"text": "Hello"}],
|
|
}
|
|
|
|
|
|
def test_assistant_message_with_text() -> None:
|
|
messages = [{"role": "assistant", "content": "Hi there"}]
|
|
converse_msgs, _ = _messages_to_converse(messages)
|
|
assert converse_msgs[0] == {
|
|
"role": "assistant",
|
|
"content": [{"text": "Hi there"}],
|
|
}
|
|
|
|
|
|
def test_assistant_tool_calls_converted_to_tool_use() -> None:
|
|
messages = [
|
|
{
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"arguments": '{"city": "London"}',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
converse_msgs, _ = _messages_to_converse(messages)
|
|
msg = converse_msgs[0]
|
|
assert msg["role"] == "assistant"
|
|
tu = msg["content"][0]["toolUse"]
|
|
assert tu["toolUseId"] == "call_1"
|
|
assert tu["name"] == "get_weather"
|
|
assert tu["input"] == {"city": "London"}
|
|
|
|
|
|
def test_tool_messages_converted_to_tool_result() -> None:
|
|
messages = [
|
|
{
|
|
"role": "tool",
|
|
"tool_call_id": "call_1",
|
|
"content": "Sunny, 22C",
|
|
}
|
|
]
|
|
converse_msgs, _ = _messages_to_converse(messages)
|
|
msg = converse_msgs[0]
|
|
assert msg["role"] == "user"
|
|
tr = msg["content"][0]["toolResult"]
|
|
assert tr["toolUseId"] == "call_1"
|
|
assert tr["content"] == [{"text": "Sunny, 22C"}]
|
|
|
|
|
|
def test_inference_config_mapped() -> None:
|
|
messages = [{"role": "user", "content": "Hi"}]
|
|
extra = {"temperature": 0.7, "top_p": 0.9, "max_tokens": 100}
|
|
kwargs = _build_converse_kwargs(messages, "model-id", None, extra)
|
|
config = kwargs["inferenceConfig"]
|
|
assert config["temperature"] == 0.7
|
|
assert config["topP"] == 0.9
|
|
assert config["maxTokens"] == 100
|
|
|
|
|
|
def test_stop_sequences_mapped() -> None:
|
|
messages = [{"role": "user", "content": "Hi"}]
|
|
extra = {"stop": ["END", "STOP"]}
|
|
kwargs = _build_converse_kwargs(messages, "model-id", None, extra)
|
|
assert kwargs["inferenceConfig"]["stopSequences"] == ["END", "STOP"]
|
|
|
|
|
|
def test_single_stop_string_wrapped_in_list() -> None:
|
|
messages = [{"role": "user", "content": "Hi"}]
|
|
extra = {"stop": "END"}
|
|
kwargs = _build_converse_kwargs(messages, "model-id", None, extra)
|
|
assert kwargs["inferenceConfig"]["stopSequences"] == ["END"]
|
|
|
|
|
|
def test_tools_converted_to_tool_spec() -> None:
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get weather",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
}
|
|
]
|
|
result = _convert_tools(tools)
|
|
assert len(result) == 1
|
|
spec = result[0]["toolSpec"]
|
|
assert spec["name"] == "get_weather"
|
|
assert spec["description"] == "Get weather"
|
|
assert spec["inputSchema"] == {"json": {"type": "object", "properties": {}}}
|
|
|
|
|
|
def test_tool_config_added_to_kwargs() -> None:
|
|
messages = [{"role": "user", "content": "Hi"}]
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "fn",
|
|
"description": "d",
|
|
"parameters": {},
|
|
},
|
|
}
|
|
]
|
|
kwargs = _build_converse_kwargs(messages, "model-id", tools, {})
|
|
assert "toolConfig" in kwargs
|
|
assert "tools" in kwargs["toolConfig"]
|
|
|
|
|
|
# ── Response translation ─────────────────────────────────
|
|
|
|
|
|
def test_converse_text_response_to_chat() -> None:
|
|
response = {
|
|
"output": {
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [{"text": "Hello!"}],
|
|
}
|
|
},
|
|
"stopReason": "end_turn",
|
|
"usage": {
|
|
"inputTokens": 10,
|
|
"outputTokens": 5,
|
|
"totalTokens": 15,
|
|
},
|
|
}
|
|
chat = _converse_to_chat(response, "bedrock-model")
|
|
assert chat["model"] == "bedrock-model"
|
|
assert chat["choices"][0]["message"]["content"] == "Hello!"
|
|
assert chat["choices"][0]["finish_reason"] == "stop"
|
|
assert chat["usage"]["prompt_tokens"] == 10
|
|
assert chat["usage"]["completion_tokens"] == 5
|
|
|
|
|
|
def test_converse_tool_use_response_to_chat() -> None:
|
|
response = {
|
|
"output": {
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"toolUse": {
|
|
"toolUseId": "tu_1",
|
|
"name": "get_weather",
|
|
"input": {"city": "London"},
|
|
}
|
|
}
|
|
],
|
|
}
|
|
},
|
|
"stopReason": "tool_use",
|
|
"usage": {},
|
|
}
|
|
chat = _converse_to_chat(response, "bedrock-model")
|
|
tool_calls = chat["choices"][0]["message"]["tool_calls"]
|
|
assert len(tool_calls) == 1
|
|
assert tool_calls[0]["id"] == "tu_1"
|
|
assert tool_calls[0]["function"]["name"] == "get_weather"
|
|
assert json.loads(tool_calls[0]["function"]["arguments"]) == {"city": "London"}
|
|
assert chat["choices"][0]["finish_reason"] == "tool_calls"
|
|
|
|
|
|
def test_converse_mixed_text_and_tool_use() -> None:
|
|
response = {
|
|
"output": {
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{"text": "Let me check."},
|
|
{
|
|
"toolUse": {
|
|
"toolUseId": "tu_1",
|
|
"name": "search",
|
|
"input": {"q": "test"},
|
|
}
|
|
},
|
|
],
|
|
}
|
|
},
|
|
"stopReason": "tool_use",
|
|
"usage": {},
|
|
}
|
|
chat = _converse_to_chat(response, "bedrock-model")
|
|
assert chat["choices"][0]["message"]["content"] == "Let me check."
|
|
assert len(chat["choices"][0]["message"]["tool_calls"]) == 1
|
|
|
|
|
|
# ── Multimodal content translation ──────────────────────
|
|
|
|
|
|
def test_user_message_with_image_data_uri() -> None:
|
|
"""
|
|
User message with image_url data URI translates to Bedrock
|
|
image block with format and bytes.
|
|
"""
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Describe this"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": "data:image/png;base64,abc123"},
|
|
},
|
|
],
|
|
},
|
|
]
|
|
converse_msgs, _ = _messages_to_converse(messages)
|
|
blocks = converse_msgs[0]["content"]
|
|
# Two blocks: text + image.
|
|
assert len(blocks) == 2
|
|
assert blocks[0] == {"text": "Describe this"}
|
|
assert blocks[1] == {
|
|
"image": {
|
|
"format": "png",
|
|
"source": {"bytes": "abc123"},
|
|
},
|
|
}
|
|
|
|
|
|
def test_user_message_with_external_url_becomes_text() -> None:
|
|
"""
|
|
External URL falls back to text placeholder since Bedrock
|
|
does not support URL references in image blocks.
|
|
"""
|
|
part = {
|
|
"type": "image_url",
|
|
"image_url": {"url": "https://example.com/photo.png"},
|
|
}
|
|
result = _translate_part_to_converse(part)
|
|
assert result == {"text": "[image: https://example.com/photo.png]"}
|
|
|
|
|
|
def test_user_message_with_file_data() -> None:
|
|
"""
|
|
input_file with file_data translates to Bedrock document block.
|
|
"""
|
|
part = {
|
|
"type": "input_file",
|
|
"file_data": "data:application/pdf;base64,JVBERi0xLjQK",
|
|
"filename": "report.pdf",
|
|
}
|
|
result = _translate_part_to_converse(part)
|
|
assert result == {
|
|
"document": {
|
|
"format": "pdf",
|
|
"name": "report.pdf",
|
|
"source": {"bytes": "JVBERi0xLjQK"},
|
|
},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mime", "expected_format"),
|
|
[
|
|
# Documented Bedrock formats and their canonical MIMEs.
|
|
# https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_DocumentBlock.html
|
|
("application/pdf", "pdf"),
|
|
("text/csv", "csv"),
|
|
("application/msword", "doc"),
|
|
(
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"docx",
|
|
),
|
|
("application/vnd.ms-excel", "xls"),
|
|
(
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"xlsx",
|
|
),
|
|
("text/html", "html"),
|
|
("text/plain", "txt"),
|
|
("text/markdown", "md"),
|
|
# Other text/* MIMEs collapse to "txt" — Bedrock's only
|
|
# generic-text bucket. The block's filename still tells the
|
|
# model the original extension.
|
|
("text/yaml", "txt"),
|
|
("text/x-rust", "txt"),
|
|
("text/typescript", "txt"),
|
|
],
|
|
)
|
|
def test_bedrock_document_format_maps_known_mimes(mime: str, expected_format: str) -> None:
|
|
"""Known + text/* MIMEs map to a Bedrock-accepted format value.
|
|
|
|
Regression check for ``text/plain`` and ``text/markdown`` — pre-fix
|
|
these went through ``media_type.split("/")[-1]`` and produced
|
|
``"plain"`` / ``"markdown"``, both rejected by the Converse API.
|
|
"""
|
|
from omnigent.llms.adapters.bedrock import _bedrock_document_format
|
|
|
|
assert _bedrock_document_format(mime) == expected_format
|
|
|
|
|
|
def test_bedrock_document_format_unknown_non_text_keeps_legacy_split() -> None:
|
|
"""Non-text MIMEs we don't recognise keep the prior split-by-``/``
|
|
behaviour, so any application/* traffic that already worked is
|
|
unaffected. The Converse API will reject formats it doesn't
|
|
accept (e.g. ``"json"``) — that's the same outcome as before this
|
|
fix, not a regression introduced here.
|
|
"""
|
|
from omnigent.llms.adapters.bedrock import _bedrock_document_format
|
|
|
|
assert _bedrock_document_format("application/json") == "json"
|
|
assert _bedrock_document_format("application/zip") == "zip"
|
|
|
|
|
|
def test_user_message_with_text_plain_file_data_uses_txt_format() -> None:
|
|
"""End-to-end: a ``text/plain`` file_data block becomes a Bedrock
|
|
document with ``format: "txt"`` (not ``"plain"``).
|
|
|
|
Pairs with the content_resolver change that coerces unsupported
|
|
file_data MIMEs to text/plain: once the resolver hands Bedrock a text/plain
|
|
data URI, the adapter must translate it into a format Bedrock
|
|
actually accepts.
|
|
"""
|
|
part = {
|
|
"type": "input_file",
|
|
"file_data": "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==",
|
|
"filename": "config.yaml",
|
|
}
|
|
result = _translate_part_to_converse(part)
|
|
assert result == {
|
|
"document": {
|
|
"format": "txt",
|
|
"name": "config.yaml",
|
|
"source": {"bytes": "SGVsbG8sIFdvcmxkIQ=="},
|
|
},
|
|
}
|
|
|
|
|
|
def test_user_message_with_markdown_file_data_uses_md_format() -> None:
|
|
"""End-to-end: ``text/markdown`` becomes ``format: "md"``.
|
|
|
|
Bedrock's enum spells the markdown format ``md``; the prior split-
|
|
by-``/`` produced ``"markdown"`` which the Converse API rejects.
|
|
"""
|
|
part = {
|
|
"type": "input_file",
|
|
"file_data": "data:text/markdown;base64,IyBIZWxsbwo=",
|
|
"filename": "README.md",
|
|
}
|
|
result = _translate_part_to_converse(part)
|
|
assert result == {
|
|
"document": {
|
|
"format": "md",
|
|
"name": "README.md",
|
|
"source": {"bytes": "IyBIZWxsbwo="},
|
|
},
|
|
}
|
|
|
|
|
|
def test_string_user_content_becomes_text_block() -> None:
|
|
"""
|
|
String user content becomes a single text block —
|
|
backward compatibility with text-only messages.
|
|
"""
|
|
messages = [{"role": "user", "content": "Hello"}]
|
|
converse_msgs, _ = _messages_to_converse(messages)
|
|
assert converse_msgs[0]["content"] == [{"text": "Hello"}]
|
|
|
|
|
|
# ── Streaming chunk helpers ──────────────────────────────
|
|
|
|
|
|
def test_stream_text_chunk_structure() -> None:
|
|
"""_stream_text_chunk builds a valid Chat Completions text delta chunk."""
|
|
from omnigent.llms.adapters.bedrock import _stream_text_chunk
|
|
|
|
chunk = _stream_text_chunk("bedrock-model", "Hello")
|
|
assert chunk["model"] == "bedrock-model"
|
|
assert chunk["object"] == "chat.completion.chunk"
|
|
assert chunk["choices"][0]["delta"]["content"] == "Hello"
|
|
assert chunk["choices"][0]["finish_reason"] is None
|
|
|
|
|
|
def test_stream_stop_chunk_structure() -> None:
|
|
"""_stream_stop_chunk builds a valid stop chunk."""
|
|
from omnigent.llms.adapters.bedrock import _stream_stop_chunk
|
|
|
|
chunk = _stream_stop_chunk("bedrock-model", "stop")
|
|
assert chunk["choices"][0]["finish_reason"] == "stop"
|
|
assert chunk["choices"][0]["delta"] == {}
|
|
|
|
|
|
def test_stream_stop_chunk_tool_calls() -> None:
|
|
"""_stream_stop_chunk for tool_use produces tool_calls finish reason."""
|
|
from omnigent.llms.adapters.bedrock import _stream_stop_chunk
|
|
|
|
chunk = _stream_stop_chunk("bedrock-model", "tool_calls")
|
|
assert chunk["choices"][0]["finish_reason"] == "tool_calls"
|
|
|
|
|
|
def test_stream_usage_chunk_structure() -> None:
|
|
"""_stream_usage_chunk builds a valid usage chunk."""
|
|
from omnigent.llms.adapters.bedrock import _stream_usage_chunk
|
|
|
|
usage = {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15}
|
|
chunk = _stream_usage_chunk("bedrock-model", usage)
|
|
assert chunk["usage"]["prompt_tokens"] == 10
|
|
assert chunk["usage"]["completion_tokens"] == 5
|
|
assert chunk["usage"]["total_tokens"] == 15
|
|
|
|
|
|
# ── None content becomes empty blocks ────────────────────
|
|
|
|
|
|
def test_none_content_becomes_empty_blocks() -> None:
|
|
"""None content yields empty content block list."""
|
|
from omnigent.llms.adapters.bedrock import _content_to_converse_blocks
|
|
|
|
assert _content_to_converse_blocks(None) == []
|
|
|
|
|
|
# ── Unrecognized part becomes text placeholder ───────────
|
|
|
|
|
|
def test_unrecognized_part_becomes_text_placeholder() -> None:
|
|
"""Unrecognized content part types render as text placeholder."""
|
|
result = _translate_part_to_converse({"type": "input_audio", "data": "base64"})
|
|
assert result == {"text": "[unsupported content: input_audio]"}
|
|
|
|
|
|
# ── file_data without data URI raises ────────────────────
|
|
|
|
|
|
def test_file_data_without_data_uri_raises() -> None:
|
|
"""input_file without a data: URI prefix raises ValueError."""
|
|
part = {
|
|
"type": "input_file",
|
|
"file_data": "https://example.com/file.pdf",
|
|
}
|
|
with pytest.raises(ValueError, match="data: URI"):
|
|
_translate_part_to_converse(part)
|
|
|
|
|
|
# ── file_data without filename ───────────────────────────
|
|
|
|
|
|
def test_file_data_without_filename() -> None:
|
|
"""input_file without filename omits name from document block."""
|
|
part = {
|
|
"type": "input_file",
|
|
"file_data": "data:application/pdf;base64,JVBERi0xLjQK",
|
|
}
|
|
result = _translate_part_to_converse(part)
|
|
assert "name" not in result["document"]
|
|
|
|
|
|
# ── Non-function tools skipped ───────────────────────────
|
|
|
|
|
|
def test_non_function_tools_skipped() -> None:
|
|
"""Non-function tool types are filtered out."""
|
|
tools = [
|
|
{"type": "not_function", "whatever": {}},
|
|
{"type": "function", "function": {"name": "fn", "parameters": {}}},
|
|
]
|
|
result = _convert_tools(tools)
|
|
assert len(result) == 1
|
|
assert result[0]["toolSpec"]["name"] == "fn"
|
|
|
|
|
|
# ── Tool without description ─────────────────────────────
|
|
|
|
|
|
def test_tool_without_description_omits_description() -> None:
|
|
"""Tool specs without description omit the field."""
|
|
tools = [
|
|
{
|
|
"type": "function",
|
|
"function": {"name": "fn", "parameters": {}},
|
|
}
|
|
]
|
|
result = _convert_tools(tools)
|
|
assert "description" not in result[0]["toolSpec"]
|
|
|
|
|
|
# ── System prompts None when absent ──────────────────────
|
|
|
|
|
|
def test_no_system_messages_returns_none_system() -> None:
|
|
"""No system messages -> system_prompts is None."""
|
|
messages = [{"role": "user", "content": "Hi"}]
|
|
_, system_prompts = _messages_to_converse(messages)
|
|
assert system_prompts is None
|
|
|
|
|
|
# ── Converse response with empty content ─────────────────
|
|
|
|
|
|
def test_converse_response_no_content() -> None:
|
|
"""Response with no text or tool content yields None content and empty tool_calls."""
|
|
response = {
|
|
"output": {"message": {"role": "assistant", "content": []}},
|
|
"stopReason": "end_turn",
|
|
"usage": {},
|
|
}
|
|
chat = _converse_to_chat(response, "bedrock-model")
|
|
assert chat["choices"][0]["message"]["content"] is None
|
|
assert chat["choices"][0]["message"]["tool_calls"] is None
|
|
|
|
|
|
# ── max_completion_tokens alias ──────────────────────────
|
|
|
|
|
|
def test_max_completion_tokens_alias() -> None:
|
|
"""max_completion_tokens is an alias for max_tokens in inference config."""
|
|
messages = [{"role": "user", "content": "Hi"}]
|
|
extra = {"max_completion_tokens": 2048}
|
|
kwargs = _build_converse_kwargs(messages, "model-id", None, extra)
|
|
assert kwargs["inferenceConfig"]["maxTokens"] == 2048
|