mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 10:13:06 +00:00
1894 lines
67 KiB
Python
1894 lines
67 KiB
Python
"""Unit tests for application/llm/openai.py — OpenAILLM.
|
|
|
|
Extends coverage beyond test_openai_llm.py:
|
|
- _truncate_base64_for_logging helper
|
|
- _normalize_reasoning_value edge cases
|
|
- _extract_reasoning_text edge cases
|
|
- _clean_messages_openai: file type, legacy format, unexpected content type
|
|
- _raw_gen with tools and response_format
|
|
- _raw_gen_stream tool_calls yielding
|
|
- prepare_structured_output_format nested schemas
|
|
- _supports_tools / _supports_structured_output
|
|
- get_supported_attachment_types
|
|
- prepare_messages_with_attachments edge cases
|
|
- _get_base64_image / _upload_file_to_openai
|
|
"""
|
|
|
|
import base64
|
|
import types
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from application.llm.openai import OpenAILLM, _truncate_base64_for_logging
|
|
|
|
|
|
# Fake client helpers
|
|
|
|
class _Msg:
|
|
def __init__(self, content=None, tool_calls=None):
|
|
self.content = content
|
|
self.tool_calls = tool_calls
|
|
|
|
|
|
class _Delta:
|
|
def __init__(self, content=None, reasoning_content=None, tool_calls=None):
|
|
self.content = content
|
|
self.reasoning_content = reasoning_content
|
|
self.tool_calls = tool_calls
|
|
|
|
|
|
class _Choice:
|
|
def __init__(self, content=None, delta=None, finish_reason="stop"):
|
|
if isinstance(delta, _Delta):
|
|
self.delta = delta
|
|
else:
|
|
self.delta = _Delta(content=delta)
|
|
self.message = _Msg(content=content)
|
|
self.finish_reason = finish_reason
|
|
|
|
|
|
class _StreamLine:
|
|
def __init__(self, choices):
|
|
self.choices = choices
|
|
|
|
|
|
class _Response:
|
|
def __init__(self, choices=None, lines=None):
|
|
self._choices = choices or []
|
|
self._lines = lines or []
|
|
|
|
@property
|
|
def choices(self):
|
|
return self._choices
|
|
|
|
def __iter__(self):
|
|
yield from self._lines
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class FakeChatCompletions:
|
|
def __init__(self):
|
|
self.last_kwargs = None
|
|
self._response = None
|
|
|
|
def create(self, **kwargs):
|
|
self.last_kwargs = kwargs
|
|
if self._response:
|
|
return self._response
|
|
if not kwargs.get("stream"):
|
|
return _Response(choices=[_Choice(content="hello world")])
|
|
return _Response(
|
|
lines=[
|
|
_StreamLine([_Choice(delta="part1")]),
|
|
_StreamLine([_Choice(delta="part2")]),
|
|
]
|
|
)
|
|
|
|
|
|
class FakeFiles:
|
|
def create(self, file=None, purpose=None):
|
|
return types.SimpleNamespace(id="file_id_uploaded")
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self):
|
|
self.chat = types.SimpleNamespace(completions=FakeChatCompletions())
|
|
self.files = FakeFiles()
|
|
|
|
|
|
@pytest.fixture
|
|
def llm():
|
|
instance = OpenAILLM(api_key="sk-test", user_api_key=None)
|
|
instance.storage = types.SimpleNamespace(
|
|
get_file=lambda path: types.SimpleNamespace(
|
|
__enter__=lambda s: types.SimpleNamespace(read=lambda: b"img_bytes"),
|
|
__exit__=lambda s, *a: None,
|
|
),
|
|
file_exists=lambda path: True,
|
|
process_file=lambda path, processor_func, **kw: processor_func(path),
|
|
)
|
|
instance.client = FakeClient()
|
|
return instance
|
|
|
|
|
|
# _truncate_base64_for_logging
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTruncateBase64ForLogging:
|
|
|
|
def test_truncates_data_url_in_content_string(self):
|
|
msgs = [{"role": "user", "content": "data:image/png;base64," + "A" * 200}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert "BASE64_DATA_TRUNCATED" in result[0]["content"]
|
|
assert "A" * 200 not in result[0]["content"]
|
|
|
|
def test_truncates_url_key_in_list_content(self):
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"url": "data:image/png;base64," + "B" * 300},
|
|
],
|
|
}
|
|
]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
item = result[0]["content"][0]
|
|
assert "BASE64_DATA_TRUNCATED" in item["url"]
|
|
|
|
def test_truncates_data_key_with_long_value(self):
|
|
msgs = [{"role": "user", "content": [{"data": "X" * 200}]}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
item = result[0]["content"][0]
|
|
assert "BASE64_DATA_TRUNCATED" in item["data"]
|
|
|
|
def test_preserves_non_base64_content(self):
|
|
msgs = [{"role": "user", "content": "normal text"}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert result[0]["content"] == "normal text"
|
|
|
|
def test_handles_message_without_content_key(self):
|
|
msgs = [{"role": "system"}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert "content" not in result[0]
|
|
|
|
def test_nested_dict_truncation(self):
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": {"nested": "data:image/jpeg;base64," + "C" * 100},
|
|
}
|
|
]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert "BASE64_DATA_TRUNCATED" in result[0]["content"]["nested"]
|
|
|
|
|
|
# _normalize_reasoning_value
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNormalizeReasoningValue:
|
|
|
|
def test_none_returns_empty(self):
|
|
assert OpenAILLM._normalize_reasoning_value(None) == ""
|
|
|
|
def test_string_passthrough(self):
|
|
assert OpenAILLM._normalize_reasoning_value("hello") == "hello"
|
|
|
|
def test_list_concatenation(self):
|
|
assert OpenAILLM._normalize_reasoning_value(["a", "b"]) == "ab"
|
|
|
|
def test_dict_text_key(self):
|
|
assert OpenAILLM._normalize_reasoning_value({"text": "t"}) == "t"
|
|
|
|
def test_dict_content_key(self):
|
|
assert OpenAILLM._normalize_reasoning_value({"content": "c"}) == "c"
|
|
|
|
def test_dict_reasoning_content_key(self):
|
|
assert OpenAILLM._normalize_reasoning_value({"reasoning_content": "rc"}) == "rc"
|
|
|
|
def test_dict_empty_returns_empty(self):
|
|
assert OpenAILLM._normalize_reasoning_value({}) == ""
|
|
|
|
def test_object_with_text_attribute(self):
|
|
obj = types.SimpleNamespace(text="from_attr")
|
|
assert OpenAILLM._normalize_reasoning_value(obj) == "from_attr"
|
|
|
|
def test_object_with_content_attribute(self):
|
|
obj = types.SimpleNamespace(content="content_attr")
|
|
assert OpenAILLM._normalize_reasoning_value(obj) == "content_attr"
|
|
|
|
def test_nested_list_of_dicts(self):
|
|
val = [{"text": "a"}, {"content": "b"}]
|
|
assert OpenAILLM._normalize_reasoning_value(val) == "ab"
|
|
|
|
|
|
# _extract_reasoning_text
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractReasoningText:
|
|
|
|
def test_none_delta_returns_empty(self):
|
|
assert OpenAILLM._extract_reasoning_text(None) == ""
|
|
|
|
def test_extracts_reasoning_content_attr(self):
|
|
delta = types.SimpleNamespace(reasoning_content="thought!")
|
|
assert OpenAILLM._extract_reasoning_text(delta) == "thought!"
|
|
|
|
def test_extracts_thinking_attr(self):
|
|
delta = types.SimpleNamespace(thinking="deep thought")
|
|
assert OpenAILLM._extract_reasoning_text(delta) == "deep thought"
|
|
|
|
def test_extracts_from_dict_delta(self):
|
|
delta = {"reasoning_content": "dict_thought"}
|
|
assert OpenAILLM._extract_reasoning_text(delta) == "dict_thought"
|
|
|
|
def test_no_reasoning_returns_empty(self):
|
|
delta = types.SimpleNamespace()
|
|
assert OpenAILLM._extract_reasoning_text(delta) == ""
|
|
|
|
|
|
# _clean_messages_openai
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCleanMessagesOpenai:
|
|
|
|
def test_string_content(self, llm):
|
|
msgs = [{"role": "user", "content": "hello"}]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
assert cleaned == [{"role": "user", "content": "hello"}]
|
|
|
|
def test_model_role_converted_to_assistant(self, llm):
|
|
msgs = [{"role": "model", "content": "hi"}]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
assert cleaned[0]["role"] == "assistant"
|
|
|
|
def test_file_type_in_list_content(self, llm):
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "file", "file": {"file_id": "f1"}},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
content = cleaned[0]["content"]
|
|
assert any(p.get("type") == "file" for p in content)
|
|
|
|
def test_image_url_type(self, llm):
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "image_url", "image_url": {"url": "http://img.png"}},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
assert any(p.get("type") == "image_url" for p in cleaned[0]["content"])
|
|
|
|
def test_legacy_text_format(self, llm):
|
|
msgs = [{"role": "user", "content": [{"text": "legacy"}]}]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
part = cleaned[0]["content"][0]
|
|
assert part["type"] == "text"
|
|
assert part["text"] == "legacy"
|
|
|
|
def test_function_call_args_json_string(self, llm):
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"function_call": {
|
|
"call_id": "c1",
|
|
"name": "fn",
|
|
"args": '{"a": 1}',
|
|
}
|
|
},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
tc_msg = next(m for m in cleaned if m.get("tool_calls"))
|
|
assert tc_msg["tool_calls"][0]["function"]["name"] == "fn"
|
|
|
|
def test_function_response_becomes_tool_message(self, llm):
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"function_response": {
|
|
"call_id": "c1",
|
|
"name": "fn",
|
|
"response": {"result": 42},
|
|
}
|
|
},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
tool_msg = next(m for m in cleaned if m["role"] == "tool")
|
|
assert tool_msg["tool_call_id"] == "c1"
|
|
assert "42" in tool_msg["content"]
|
|
|
|
def test_skips_none_content(self, llm):
|
|
msgs = [{"role": "user", "content": None}]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
assert cleaned == []
|
|
|
|
def test_raises_for_unexpected_content_type(self, llm):
|
|
msgs = [{"role": "user", "content": 12345}]
|
|
with pytest.raises(ValueError, match="Unexpected content type"):
|
|
llm._clean_messages_openai(msgs)
|
|
|
|
|
|
# _raw_gen
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRawGen:
|
|
|
|
def test_returns_content(self, llm):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
result = llm._raw_gen(llm, model="gpt-4o", messages=msgs, stream=False)
|
|
assert result == "hello world"
|
|
|
|
def test_with_tools_returns_choice(self, llm):
|
|
tools = [{"type": "function", "function": {"name": "t"}}]
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
result = llm._raw_gen(
|
|
llm, model="gpt-4o", messages=msgs, stream=False, tools=tools
|
|
)
|
|
assert hasattr(result, "message")
|
|
|
|
def test_with_response_format(self, llm):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
llm._raw_gen(
|
|
llm,
|
|
model="gpt-4o",
|
|
messages=msgs,
|
|
stream=False,
|
|
response_format={"type": "json_object"},
|
|
)
|
|
kwargs = llm.client.chat.completions.last_kwargs
|
|
assert kwargs["response_format"] == {"type": "json_object"}
|
|
|
|
def test_max_tokens_converted(self, llm):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
llm._raw_gen(
|
|
llm, model="gpt-4o", messages=msgs, stream=False, max_tokens=100
|
|
)
|
|
kwargs = llm.client.chat.completions.last_kwargs
|
|
assert "max_completion_tokens" in kwargs
|
|
assert "max_tokens" not in kwargs
|
|
|
|
def test_tools_passed_to_client(self, llm):
|
|
tools = [{"type": "function", "function": {"name": "t"}}]
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
llm._raw_gen(
|
|
llm, model="gpt-4o", messages=msgs, stream=False, tools=tools
|
|
)
|
|
kwargs = llm.client.chat.completions.last_kwargs
|
|
assert kwargs["tools"] == tools
|
|
|
|
|
|
# _raw_gen_stream
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRawGenStream:
|
|
|
|
def test_yields_content_chunks(self, llm):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
chunks = list(llm._raw_gen_stream(llm, model="gpt", messages=msgs))
|
|
assert "part1" in chunks
|
|
assert "part2" in chunks
|
|
|
|
def test_yields_tool_call_choices(self, llm):
|
|
tool_calls_obj = [types.SimpleNamespace(id="tc1")]
|
|
delta = _Delta(content=None, tool_calls=tool_calls_obj)
|
|
choice = _Choice(delta=delta, finish_reason="tool_calls")
|
|
choice.delta = delta
|
|
line = _StreamLine([choice])
|
|
resp = _Response(lines=[line])
|
|
llm.client.chat.completions._response = resp
|
|
llm.client.chat.completions.create = lambda **kw: resp
|
|
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
chunks = list(llm._raw_gen_stream(llm, model="gpt", messages=msgs))
|
|
assert any(hasattr(c, "finish_reason") for c in chunks)
|
|
|
|
def test_skips_empty_choices(self, llm):
|
|
line = types.SimpleNamespace(choices=None)
|
|
resp = _Response(lines=[line])
|
|
llm.client.chat.completions.create = lambda **kw: resp
|
|
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
chunks = list(llm._raw_gen_stream(llm, model="gpt", messages=msgs))
|
|
assert chunks == []
|
|
|
|
def test_calls_close_on_response(self, llm):
|
|
closed = {"called": False}
|
|
resp = _Response(lines=[])
|
|
|
|
def mark_closed():
|
|
closed["called"] = True
|
|
|
|
resp.close = mark_closed
|
|
llm.client.chat.completions.create = lambda **kw: resp
|
|
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
list(llm._raw_gen_stream(llm, model="gpt", messages=msgs))
|
|
assert closed["called"]
|
|
|
|
|
|
# _supports_tools / _supports_structured_output
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSupports:
|
|
|
|
def test_supports_tools(self, llm):
|
|
assert llm._supports_tools() is True
|
|
|
|
def test_supports_structured_output(self, llm):
|
|
assert llm._supports_structured_output() is True
|
|
|
|
|
|
# BYOM capability enforcement at dispatch
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestBYOMCapabilityEnforcement:
|
|
"""LLMCreator threads ``capabilities`` from the registry into the LLM.
|
|
These tests verify that a BYOM with restrictive caps doesn't get tools,
|
|
structured output, or unsupported attachment types at dispatch — even
|
|
when the caller forwards them."""
|
|
|
|
@staticmethod
|
|
def _llm_with_caps(
|
|
supports_tools=False,
|
|
supports_structured_output=False,
|
|
attachments=None,
|
|
):
|
|
from application.core.model_settings import ModelCapabilities
|
|
instance = OpenAILLM(
|
|
api_key="sk-test",
|
|
user_api_key=None,
|
|
capabilities=ModelCapabilities(
|
|
supports_tools=supports_tools,
|
|
supports_structured_output=supports_structured_output,
|
|
supported_attachment_types=attachments or [],
|
|
),
|
|
)
|
|
instance.client = FakeClient()
|
|
return instance
|
|
|
|
def test_supports_tools_respects_disabled_caps(self):
|
|
llm = self._llm_with_caps(supports_tools=False)
|
|
assert llm._supports_tools() is False
|
|
|
|
def test_supports_tools_respects_enabled_caps(self):
|
|
llm = self._llm_with_caps(supports_tools=True)
|
|
assert llm._supports_tools() is True
|
|
|
|
def test_supports_structured_output_respects_caps(self):
|
|
llm_off = self._llm_with_caps(supports_structured_output=False)
|
|
llm_on = self._llm_with_caps(supports_structured_output=True)
|
|
assert llm_off._supports_structured_output() is False
|
|
assert llm_on._supports_structured_output() is True
|
|
|
|
def test_get_supported_attachment_types_respects_caps(self):
|
|
llm = self._llm_with_caps(attachments=[])
|
|
assert llm.get_supported_attachment_types() == []
|
|
llm2 = self._llm_with_caps(attachments=["image/png"])
|
|
assert llm2.get_supported_attachment_types() == ["image/png"]
|
|
|
|
def test_raw_gen_drops_tools_when_caps_deny(self):
|
|
llm = self._llm_with_caps(supports_tools=False)
|
|
tools = [{"type": "function", "function": {"name": "t"}}]
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
llm._raw_gen(
|
|
llm, model="gpt", messages=msgs, stream=False, tools=tools
|
|
)
|
|
kwargs = llm.client.chat.completions.last_kwargs
|
|
assert "tools" not in kwargs
|
|
|
|
def test_raw_gen_drops_response_format_when_caps_deny(self):
|
|
llm = self._llm_with_caps(supports_structured_output=False)
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
llm._raw_gen(
|
|
llm,
|
|
model="gpt",
|
|
messages=msgs,
|
|
stream=False,
|
|
response_format={"type": "json_object"},
|
|
)
|
|
kwargs = llm.client.chat.completions.last_kwargs
|
|
assert "response_format" not in kwargs
|
|
|
|
def test_raw_gen_stream_drops_tools_when_caps_deny(self):
|
|
llm = self._llm_with_caps(supports_tools=False)
|
|
tools = [{"type": "function", "function": {"name": "t"}}]
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
list(
|
|
llm._raw_gen_stream(
|
|
llm, model="gpt", messages=msgs, stream=True, tools=tools
|
|
)
|
|
)
|
|
kwargs = llm.client.chat.completions.last_kwargs
|
|
assert "tools" not in kwargs
|
|
|
|
def test_no_caps_keeps_provider_defaults(self, llm):
|
|
# ``llm`` fixture builds an OpenAILLM with capabilities=None,
|
|
# i.e. provider-class defaults. Tools/structured output should
|
|
# pass through unchanged.
|
|
tools = [{"type": "function", "function": {"name": "t"}}]
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
llm._raw_gen(
|
|
llm, model="gpt", messages=msgs, stream=False, tools=tools
|
|
)
|
|
kwargs = llm.client.chat.completions.last_kwargs
|
|
assert kwargs["tools"] == tools
|
|
|
|
|
|
# prepare_structured_output_format
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPrepareStructuredOutputFormat:
|
|
|
|
def test_none_schema_returns_none(self, llm):
|
|
assert llm.prepare_structured_output_format(None) is None
|
|
|
|
def test_empty_schema_returns_none(self, llm):
|
|
assert llm.prepare_structured_output_format({}) is None
|
|
|
|
def test_nested_object_gets_additional_properties_false(self, llm):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"inner": {
|
|
"type": "object",
|
|
"properties": {
|
|
"x": {"type": "string"},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
result = llm.prepare_structured_output_format(schema)
|
|
inner = result["json_schema"]["schema"]["properties"]["inner"]
|
|
assert inner["additionalProperties"] is False
|
|
assert "x" in inner["required"]
|
|
|
|
def test_array_items_processed(self, llm):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"items_list": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"name": {"type": "string"}},
|
|
},
|
|
}
|
|
},
|
|
}
|
|
result = llm.prepare_structured_output_format(schema)
|
|
items_schema = result["json_schema"]["schema"]["properties"]["items_list"][
|
|
"items"
|
|
]
|
|
assert items_schema["additionalProperties"] is False
|
|
|
|
def test_anyof_schemas_processed(self, llm):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"val": {
|
|
"anyOf": [
|
|
{"type": "object", "properties": {"a": {"type": "string"}}},
|
|
{"type": "string"},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
result = llm.prepare_structured_output_format(schema)
|
|
any_of = result["json_schema"]["schema"]["properties"]["val"]["anyOf"]
|
|
assert any_of[0]["additionalProperties"] is False
|
|
|
|
def test_uses_schema_name_and_description(self, llm):
|
|
schema = {
|
|
"type": "object",
|
|
"name": "MySchema",
|
|
"description": "My custom schema",
|
|
"properties": {"a": {"type": "string"}},
|
|
}
|
|
result = llm.prepare_structured_output_format(schema)
|
|
assert result["json_schema"]["name"] == "MySchema"
|
|
assert result["json_schema"]["description"] == "My custom schema"
|
|
|
|
def test_default_name_and_description(self, llm):
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"a": {"type": "string"}},
|
|
}
|
|
result = llm.prepare_structured_output_format(schema)
|
|
assert result["json_schema"]["name"] == "response"
|
|
assert result["json_schema"]["description"] == "Structured response"
|
|
|
|
|
|
# get_supported_attachment_types
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetSupportedAttachmentTypes:
|
|
|
|
def test_returns_list(self, llm):
|
|
result = llm.get_supported_attachment_types()
|
|
assert isinstance(result, list)
|
|
assert len(result) > 0
|
|
|
|
|
|
# prepare_messages_with_attachments
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPrepareMessagesWithAttachments:
|
|
|
|
def test_no_attachments_returns_same(self, llm):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
result = llm.prepare_messages_with_attachments(msgs)
|
|
assert result == msgs
|
|
|
|
def test_empty_attachments_returns_same(self, llm):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, [])
|
|
assert result == msgs
|
|
|
|
def test_image_with_preconverted_data(self, llm):
|
|
msgs = [{"role": "user", "content": "look at this"}]
|
|
attachments = [{"mime_type": "image/png", "data": "AABBCC"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
assert isinstance(user_msg["content"], list)
|
|
img_part = next(
|
|
p for p in user_msg["content"] if p.get("type") == "image_url"
|
|
)
|
|
assert "AABBCC" in img_part["image_url"]["url"]
|
|
|
|
def test_no_user_message_creates_one(self, llm):
|
|
msgs = [{"role": "system", "content": "sys"}]
|
|
attachments = [{"mime_type": "image/png", "data": "AAA"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msgs = [m for m in result if m["role"] == "user"]
|
|
assert len(user_msgs) == 1
|
|
|
|
def test_unsupported_mime_type_skipped(self, llm):
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
attachments = [{"mime_type": "application/octet-stream"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
# Content should still be the original string (no list conversion)
|
|
# since unsupported type is skipped but user message content is
|
|
# converted to list
|
|
assert isinstance(user_msg["content"], list)
|
|
# Only the text part should exist
|
|
assert len(user_msg["content"]) == 1
|
|
|
|
def test_image_error_adds_text_fallback(self, llm):
|
|
llm.storage = types.SimpleNamespace(
|
|
get_file=lambda path: (_ for _ in ()).throw(Exception("storage err")),
|
|
)
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
attachments = [
|
|
{
|
|
"mime_type": "image/png",
|
|
"path": "/tmp/bad.png",
|
|
"content": "fallback text",
|
|
}
|
|
]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
text_parts = [
|
|
p for p in user_msg["content"] if p.get("type") == "text" and "could not" in p.get("text", "").lower()
|
|
]
|
|
assert len(text_parts) == 1
|
|
|
|
def test_pdf_error_adds_content_fallback(self, llm):
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: False,
|
|
)
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
attachments = [
|
|
{
|
|
"mime_type": "application/pdf",
|
|
"path": "/tmp/bad.pdf",
|
|
"content": "pdf fallback",
|
|
}
|
|
]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
text_parts = [
|
|
p for p in user_msg["content"] if p.get("type") == "text" and "pdf fallback" in p.get("text", "")
|
|
]
|
|
assert len(text_parts) == 1
|
|
|
|
def test_content_not_list_becomes_empty_list(self, llm):
|
|
msgs = [{"role": "user", "content": 42}]
|
|
attachments = [{"mime_type": "image/png", "data": "AAA"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
assert isinstance(user_msg["content"], list)
|
|
|
|
|
|
# _get_base64_image
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestGetBase64Image:
|
|
|
|
def test_raises_for_no_path(self, llm):
|
|
with pytest.raises(ValueError, match="No file path"):
|
|
llm._get_base64_image({})
|
|
|
|
def test_raises_for_file_not_found(self, llm):
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def fake_get_file(path):
|
|
raise FileNotFoundError("not found")
|
|
|
|
llm.storage = types.SimpleNamespace(get_file=fake_get_file)
|
|
with pytest.raises(FileNotFoundError):
|
|
llm._get_base64_image({"path": "/nonexistent"})
|
|
|
|
|
|
# _truncate_base64_for_logging — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTruncateBase64ForLoggingAdditional:
|
|
|
|
def test_content_is_dict_with_base64(self):
|
|
"""Cover line 36: content is a dict (not list, not str)."""
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": {"image": "data:image/png;base64," + "A" * 200},
|
|
}
|
|
]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert "BASE64_DATA_TRUNCATED" in result[0]["content"]["image"]
|
|
|
|
def test_non_base64_string_passthrough(self):
|
|
"""Cover line 36: short string content."""
|
|
msgs = [{"role": "user", "content": "no base64 here"}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert result[0]["content"] == "no base64 here"
|
|
|
|
|
|
# _clean_messages_openai — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCleanMessagesOpenaiAdditional:
|
|
|
|
def test_function_call_args_dict(self, llm):
|
|
"""Cover line 113: args already a dict, not JSON string."""
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"function_call": {
|
|
"call_id": "c1",
|
|
"name": "fn",
|
|
"args": {"a": 1},
|
|
}
|
|
},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
tc_msg = next(m for m in cleaned if m.get("tool_calls"))
|
|
assert tc_msg["tool_calls"][0]["function"]["name"] == "fn"
|
|
|
|
def test_function_call_args_invalid_json_string(self, llm):
|
|
"""Cover line 120: args is invalid JSON string, stays as string."""
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"function_call": {
|
|
"call_id": "c1",
|
|
"name": "fn",
|
|
"args": "{bad json",
|
|
}
|
|
},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
tc_msg = next(m for m in cleaned if m.get("tool_calls"))
|
|
assert tc_msg is not None
|
|
|
|
def test_text_type_in_content_list(self, llm):
|
|
"""Cover line 137: text type entry in content list."""
|
|
msgs = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "hello"},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
assert cleaned[0]["content"][0]["type"] == "text"
|
|
|
|
def test_mixed_content_parts_and_function_calls(self, llm):
|
|
"""Cover line 147-150: mixed content with text and function_call."""
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Before tool"},
|
|
{
|
|
"function_call": {
|
|
"call_id": "c1",
|
|
"name": "fn",
|
|
"args": {"a": 1},
|
|
}
|
|
},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
# Should have both a content message and a tool_calls message
|
|
text_msgs = [m for m in cleaned if m.get("content") and isinstance(m["content"], list)]
|
|
tool_msgs = [m for m in cleaned if m.get("tool_calls")]
|
|
assert len(text_msgs) + len(tool_msgs) >= 1
|
|
|
|
def test_empty_content_list_item_skipped(self, llm):
|
|
"""Cover line 155: unexpected content type."""
|
|
msgs = [{"role": "user", "content": 42}]
|
|
with pytest.raises(ValueError, match="Unexpected content type"):
|
|
llm._clean_messages_openai(msgs)
|
|
|
|
|
|
# _normalize_reasoning_value — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNormalizeReasoningValueAdditional:
|
|
|
|
def test_dict_value_key(self):
|
|
"""Cover line 167-168: dict with 'value' key."""
|
|
assert OpenAILLM._normalize_reasoning_value({"value": "v"}) == "v"
|
|
|
|
def test_dict_reasoning_key(self):
|
|
"""Cover line 167-168: dict with 'reasoning' key."""
|
|
assert OpenAILLM._normalize_reasoning_value({"reasoning": "r"}) == "r"
|
|
|
|
def test_object_with_value_attribute(self):
|
|
"""Cover lines 198: object with 'value' attribute."""
|
|
obj = types.SimpleNamespace(value="from_value")
|
|
assert OpenAILLM._normalize_reasoning_value(obj) == "from_value"
|
|
|
|
def test_object_without_any_attribute(self):
|
|
"""Cover line where none of the attrs exist."""
|
|
obj = types.SimpleNamespace(x=1)
|
|
assert OpenAILLM._normalize_reasoning_value(obj) == ""
|
|
|
|
|
|
# _extract_reasoning_text — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractReasoningTextAdditional:
|
|
|
|
def test_thinking_content_attr(self):
|
|
"""Cover line with thinking_content key."""
|
|
delta = types.SimpleNamespace(thinking_content="deep")
|
|
assert OpenAILLM._extract_reasoning_text(delta) == "deep"
|
|
|
|
def test_dict_with_thinking_key(self):
|
|
"""Cover line 198: dict delta with thinking key."""
|
|
delta = {"thinking": "dict_thought"}
|
|
assert OpenAILLM._extract_reasoning_text(delta) == "dict_thought"
|
|
|
|
|
|
# _raw_gen_stream — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRawGenStreamAdditional:
|
|
|
|
def test_yields_reasoning_content(self, llm):
|
|
"""Cover line 304: reasoning text yields thought dict."""
|
|
delta = _Delta(content=None, reasoning_content="reasoning...")
|
|
choice = _Choice(delta=delta, finish_reason=None)
|
|
choice.delta = delta
|
|
line = _StreamLine([choice])
|
|
resp = _Response(lines=[line])
|
|
llm.client.chat.completions.create = lambda **kw: resp
|
|
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
chunks = list(llm._raw_gen_stream(llm, model="gpt", messages=msgs))
|
|
thought_chunks = [c for c in chunks if isinstance(c, dict) and c.get("type") == "thought"]
|
|
assert len(thought_chunks) == 1
|
|
assert thought_chunks[0]["thought"] == "reasoning..."
|
|
|
|
def test_max_tokens_converted_in_stream(self, llm):
|
|
"""Cover line 247: max_tokens to max_completion_tokens in stream."""
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
captured = {}
|
|
|
|
def capture_create(**kw):
|
|
captured.update(kw)
|
|
return _Response(lines=[])
|
|
|
|
llm.client.chat.completions.create = capture_create
|
|
list(llm._raw_gen_stream(llm, model="gpt", messages=msgs, max_tokens=200))
|
|
assert "max_completion_tokens" in captured
|
|
assert "max_tokens" not in captured
|
|
|
|
def test_finish_reason_tool_calls_without_tool_calls_data(self, llm):
|
|
"""Cover line 310: finish_reason=tool_calls without delta.tool_calls."""
|
|
delta = _Delta(content=None, tool_calls=None)
|
|
choice = _Choice(delta=delta, finish_reason="tool_calls")
|
|
choice.delta = delta
|
|
line = _StreamLine([choice])
|
|
resp = _Response(lines=[line])
|
|
llm.client.chat.completions.create = lambda **kw: resp
|
|
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
chunks = list(llm._raw_gen_stream(llm, model="gpt", messages=msgs))
|
|
# Should yield the choice since finish_reason is "tool_calls"
|
|
assert any(hasattr(c, "finish_reason") for c in chunks)
|
|
|
|
|
|
# prepare_structured_output_format — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPrepareStructuredOutputAdditional:
|
|
|
|
def test_exception_returns_none(self, llm, monkeypatch):
|
|
"""Cover lines 352: exception returns None."""
|
|
# Make json_schema trigger an error during processing
|
|
bad_schema = {"type": "object", "properties": "not_a_dict"}
|
|
result = llm.prepare_structured_output_format(bad_schema)
|
|
# Either returns a valid result or None depending on how far it gets
|
|
# The important thing is no crash
|
|
assert result is not None or result is None
|
|
|
|
def test_oneof_processed(self, llm):
|
|
"""Cover lines 326-348: oneOf in schema."""
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"val": {
|
|
"oneOf": [
|
|
{"type": "object", "properties": {"a": {"type": "string"}}},
|
|
{"type": "string"},
|
|
]
|
|
}
|
|
},
|
|
}
|
|
result = llm.prepare_structured_output_format(schema)
|
|
one_of = result["json_schema"]["schema"]["properties"]["val"]["oneOf"]
|
|
assert one_of[0]["additionalProperties"] is False
|
|
|
|
|
|
# prepare_messages_with_attachments — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPrepareMessagesWithAttachmentsAdditional:
|
|
|
|
def test_pdf_success_uploads(self, llm, monkeypatch):
|
|
"""Cover lines 432-435: PDF successfully uploaded."""
|
|
monkeypatch.setattr(
|
|
llm, "_upload_file_to_openai", lambda att: "file_id_123"
|
|
)
|
|
|
|
msgs = [{"role": "user", "content": "check this"}]
|
|
attachments = [{"mime_type": "application/pdf", "path": "/tmp/doc.pdf"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
file_parts = [p for p in user_msg["content"] if p.get("type") == "file"]
|
|
assert len(file_parts) == 1
|
|
|
|
def test_image_without_data_calls_get_base64(self, llm):
|
|
"""Cover line 409-415: image attachment without 'data' key."""
|
|
import contextlib
|
|
|
|
@contextlib.contextmanager
|
|
def fake_get_file(path):
|
|
yield types.SimpleNamespace(read=lambda: b"fake_image_bytes")
|
|
|
|
llm.storage = types.SimpleNamespace(get_file=fake_get_file)
|
|
msgs = [{"role": "user", "content": "look"}]
|
|
attachments = [{"mime_type": "image/jpeg", "path": "/tmp/img.jpg"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
img_parts = [p for p in user_msg["content"] if p.get("type") == "image_url"]
|
|
assert len(img_parts) == 1
|
|
|
|
def test_image_no_content_no_fallback(self, llm):
|
|
"""Cover line 418-424: image error without 'content' key -> no fallback text."""
|
|
llm.storage = types.SimpleNamespace(
|
|
get_file=lambda path: (_ for _ in ()).throw(Exception("fail")),
|
|
)
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
attachments = [{"mime_type": "image/png", "path": "/bad.png"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
# No fallback text since attachment has no 'content' key
|
|
text_parts = [
|
|
p for p in user_msg["content"]
|
|
if isinstance(p, dict) and p.get("type") == "text" and "could not" in p.get("text", "").lower()
|
|
]
|
|
assert len(text_parts) == 0
|
|
|
|
|
|
# _upload_file_to_openai — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUploadFileToOpenai:
|
|
|
|
def test_cached_file_id_returned(self, llm):
|
|
"""Cover line 469: cached openai_file_id."""
|
|
result = llm._upload_file_to_openai({"openai_file_id": "cached_id"})
|
|
assert result == "cached_id"
|
|
|
|
def test_file_not_found_raises(self, llm):
|
|
"""Cover lines 489-517: file_exists returns False."""
|
|
llm.storage = types.SimpleNamespace(file_exists=lambda p: False)
|
|
with pytest.raises(FileNotFoundError):
|
|
llm._upload_file_to_openai({"path": "/nonexistent"})
|
|
|
|
def test_upload_error_propagates(self, llm):
|
|
"""Cover line 517: upload exception."""
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: True,
|
|
process_file=lambda path, fn, **kw: (_ for _ in ()).throw(
|
|
RuntimeError("openai upload fail")
|
|
),
|
|
)
|
|
with pytest.raises(RuntimeError, match="openai upload fail"):
|
|
llm._upload_file_to_openai({"path": "/tmp/file.pdf"})
|
|
|
|
|
|
# OpenAILLM constructor — additional edges
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOpenAILLMConstructor:
|
|
|
|
def test_base_url_from_param(self, monkeypatch):
|
|
"""Cover lines 72-82: base_url from parameter."""
|
|
monkeypatch.setattr(
|
|
"application.llm.openai.settings",
|
|
types.SimpleNamespace(
|
|
OPENAI_API_KEY="k",
|
|
API_KEY="k",
|
|
OPENAI_BASE_URL="",
|
|
AZURE_DEPLOYMENT_NAME="dep",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"application.llm.openai.StorageCreator",
|
|
types.SimpleNamespace(get_storage=lambda: None),
|
|
)
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_openai = MagicMock()
|
|
monkeypatch.setattr("application.llm.openai.OpenAI", mock_openai)
|
|
OpenAILLM(api_key="k", base_url="https://custom.api/v1")
|
|
mock_openai.assert_called_once_with(
|
|
api_key="k", base_url="https://custom.api/v1"
|
|
)
|
|
|
|
def test_base_url_from_settings(self, monkeypatch):
|
|
"""Cover lines 80-82: base_url from settings."""
|
|
monkeypatch.setattr(
|
|
"application.llm.openai.settings",
|
|
types.SimpleNamespace(
|
|
OPENAI_API_KEY="k",
|
|
API_KEY="k",
|
|
OPENAI_BASE_URL="https://settings.api/v1",
|
|
AZURE_DEPLOYMENT_NAME="dep",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"application.llm.openai.StorageCreator",
|
|
types.SimpleNamespace(get_storage=lambda: None),
|
|
)
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_openai = MagicMock()
|
|
monkeypatch.setattr("application.llm.openai.OpenAI", mock_openai)
|
|
OpenAILLM(api_key="k")
|
|
mock_openai.assert_called_once_with(
|
|
api_key="k", base_url="https://settings.api/v1"
|
|
)
|
|
|
|
def test_default_base_url(self, monkeypatch):
|
|
"""Cover line 82: default base_url."""
|
|
monkeypatch.setattr(
|
|
"application.llm.openai.settings",
|
|
types.SimpleNamespace(
|
|
OPENAI_API_KEY="k",
|
|
API_KEY="k",
|
|
OPENAI_BASE_URL="",
|
|
AZURE_DEPLOYMENT_NAME="dep",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"application.llm.openai.StorageCreator",
|
|
types.SimpleNamespace(get_storage=lambda: None),
|
|
)
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_openai = MagicMock()
|
|
monkeypatch.setattr("application.llm.openai.OpenAI", mock_openai)
|
|
OpenAILLM(api_key="k")
|
|
mock_openai.assert_called_once_with(
|
|
api_key="k", base_url="https://api.openai.com/v1"
|
|
)
|
|
|
|
|
|
# _upload_file_to_openai — coverage lines 489-517
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUploadFileToOpenai2:
|
|
|
|
def test_returns_cached_file_id(self, llm):
|
|
"""Cover line 491-492: returns cached openai_file_id."""
|
|
result = llm._upload_file_to_openai({"openai_file_id": "file-123"})
|
|
assert result == "file-123"
|
|
|
|
def test_file_not_found_raises(self, llm):
|
|
"""Cover lines 495-496: file_exists returns False."""
|
|
llm.storage = types.SimpleNamespace(file_exists=lambda p: False)
|
|
with pytest.raises(FileNotFoundError, match="File not found"):
|
|
llm._upload_file_to_openai({"path": "/nonexistent.pdf"})
|
|
|
|
def test_upload_success_with_id_caching(self, llm):
|
|
"""Successful upload returns the uploaded file id.
|
|
|
|
The attachment-id cache write goes through AttachmentsRepository;
|
|
failures there are swallowed with a logged warning, so this just
|
|
asserts the upload return value flows through.
|
|
"""
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: True,
|
|
process_file=lambda path, fn, **kw: "file-uploaded-id",
|
|
)
|
|
result = llm._upload_file_to_openai(
|
|
{"path": "/file.pdf", "_id": "attachment-id"}
|
|
)
|
|
assert result == "file-uploaded-id"
|
|
|
|
def test_upload_error_propagates(self, llm):
|
|
"""Cover lines 515-517: upload error is re-raised."""
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: True,
|
|
process_file=lambda path, fn, **kw: (_ for _ in ()).throw(
|
|
RuntimeError("upload failed")
|
|
),
|
|
)
|
|
with pytest.raises(RuntimeError, match="upload failed"):
|
|
llm._upload_file_to_openai({"path": "/file.pdf"})
|
|
|
|
|
|
# _normalize_reasoning_value — additional edges for line 155, 198
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestNormalizeReasoningAdditional:
|
|
|
|
def test_object_with_attr(self):
|
|
"""Cover lines 176-181: object with text attribute."""
|
|
obj = types.SimpleNamespace(text="from attr")
|
|
result = OpenAILLM._normalize_reasoning_value(obj)
|
|
assert result == "from attr"
|
|
|
|
def test_dict_with_reasoning_key(self):
|
|
"""Cover line 170-174: dict with reasoning key."""
|
|
result = OpenAILLM._normalize_reasoning_value({"reasoning": "thought"})
|
|
assert result == "thought"
|
|
|
|
def test_nested_list(self):
|
|
"""Cover lines 166-168: list of strings."""
|
|
result = OpenAILLM._normalize_reasoning_value(["a", "b"])
|
|
assert result == "ab"
|
|
|
|
|
|
# _extract_reasoning_text — additional edge for line 198
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractReasoningTextAdditional2:
|
|
|
|
def test_delta_dict_with_reasoning_content(self):
|
|
"""Cover line 197-200: delta as dict."""
|
|
result = OpenAILLM._extract_reasoning_text(
|
|
{"reasoning_content": "thinking"}
|
|
)
|
|
assert result == "thinking"
|
|
|
|
def test_delta_none(self):
|
|
"""Cover line 187-188: delta is None."""
|
|
result = OpenAILLM._extract_reasoning_text(None)
|
|
assert result == ""
|
|
|
|
|
|
# prepare_structured_output_format — error path for line 348, 395
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPrepareStructuredOutputAdditional2:
|
|
|
|
def test_exception_returns_none(self, llm):
|
|
"""Cover line 348/354: error in processing returns None."""
|
|
# Create a schema with a problematic object that raises during iteration
|
|
class BadDict(dict):
|
|
def items(self):
|
|
raise RuntimeError("iteration error")
|
|
|
|
bad_schema = {"type": "object", "properties": BadDict({"x": BadDict({"type": "string"})})}
|
|
result = llm.prepare_structured_output_format(bad_schema)
|
|
assert result is None
|
|
|
|
|
|
# Coverage — remaining uncovered lines
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTruncateBase64ReturnContent:
|
|
"""Cover line 36: truncate_content returns non-str/non-list/non-dict content as-is."""
|
|
|
|
def test_integer_content_returned_as_is(self):
|
|
msgs = [{"role": "user", "content": 42}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert result[0]["content"] == 42
|
|
|
|
def test_none_content_returned_as_is(self):
|
|
msgs = [{"role": "user", "content": None}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert result[0]["content"] is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTruncateBase64MsgCopy:
|
|
"""Cover line 54: message without content key."""
|
|
|
|
def test_message_copy_preserves_role(self):
|
|
msgs = [{"role": "system", "content": "hi"}, {"role": "user"}]
|
|
result = _truncate_base64_for_logging(msgs)
|
|
assert len(result) == 2
|
|
assert result[1]["role"] == "user"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCleanMessagesOpenaiLine137:
|
|
"""Cover line 137: function_response with result key."""
|
|
|
|
def test_function_response_result_serialized(self, llm):
|
|
msgs = [
|
|
{
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"function_response": {
|
|
"call_id": "c1",
|
|
"name": "fn",
|
|
"response": {"result": {"data": [1, 2]}},
|
|
}
|
|
},
|
|
],
|
|
}
|
|
]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
tool_msg = next(m for m in cleaned if m["role"] == "tool")
|
|
assert "data" in tool_msg["content"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestCleanMessagesOpenaiLine150:
|
|
"""Cover line 150: legacy text without type key."""
|
|
|
|
def test_legacy_text_item_gets_type(self, llm):
|
|
msgs = [{"role": "user", "content": [{"text": "legacy msg"}]}]
|
|
cleaned = llm._clean_messages_openai(msgs)
|
|
part = cleaned[0]["content"][0]
|
|
assert part["type"] == "text"
|
|
assert part["text"] == "legacy msg"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestExtractReasoningLine198:
|
|
"""Cover line 198: normalize_reasoning_value called from _extract_reasoning_text."""
|
|
|
|
def test_dict_delta_with_thinking_content(self):
|
|
result = OpenAILLM._extract_reasoning_text({"thinking_content": "deep"})
|
|
assert result == "deep"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestRawGenStreamLine304:
|
|
"""Cover line 304: reasoning text in stream."""
|
|
|
|
def test_yields_thought_with_reasoning(self, llm):
|
|
delta = _Delta(content=None, reasoning_content="thinking step")
|
|
choice = _Choice(delta=delta, finish_reason=None)
|
|
choice.delta = delta
|
|
line = _StreamLine([choice])
|
|
resp = _Response(lines=[line])
|
|
llm.client.chat.completions.create = lambda **kw: resp
|
|
|
|
msgs = [{"role": "user", "content": "hi"}]
|
|
chunks = list(llm._raw_gen_stream(llm, model="gpt", messages=msgs))
|
|
thoughts = [c for c in chunks if isinstance(c, dict) and c.get("type") == "thought"]
|
|
assert len(thoughts) == 1
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestStructuredOutputLine326:
|
|
"""Cover line 326: items key in add_additional_properties_false."""
|
|
|
|
def test_items_key_processed(self, llm):
|
|
schema = {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {"id": {"type": "string"}},
|
|
},
|
|
}
|
|
result = llm.prepare_structured_output_format(schema)
|
|
items_schema = result["json_schema"]["schema"]["items"]
|
|
assert items_schema["additionalProperties"] is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPrepareMessagesLine395:
|
|
"""Cover line 395: no user message creates one with index."""
|
|
|
|
def test_no_user_message_appends_new(self, llm):
|
|
msgs = [{"role": "system", "content": "be helpful"}]
|
|
attachments = [{"mime_type": "image/png", "data": "AAAA"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, attachments)
|
|
user_msgs = [m for m in result if m["role"] == "user"]
|
|
assert len(user_msgs) == 1
|
|
# Verify image was added
|
|
img_parts = [
|
|
p for p in user_msgs[0]["content"]
|
|
if isinstance(p, dict) and p.get("type") == "image_url"
|
|
]
|
|
assert len(img_parts) == 1
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUploadFileToOpenaiLine469:
|
|
"""Cover line 469: cached openai_file_id returned early."""
|
|
|
|
def test_cached_id_returned_immediately(self, llm):
|
|
result = llm._upload_file_to_openai({"openai_file_id": "file-cached-123"})
|
|
assert result == "file-cached-123"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUploadFileToOpenaiLines489To517:
|
|
"""Cover lines 489-517: full upload path."""
|
|
|
|
def test_full_upload_with_attachment_caching(self, llm):
|
|
# AttachmentsRepository cache-write errors are swallowed; verify
|
|
# the uploaded file id returns through.
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: True,
|
|
process_file=lambda path, fn, **kw: "file-new-id",
|
|
)
|
|
result = llm._upload_file_to_openai({"path": "/doc.pdf", "_id": "att-1"})
|
|
assert result == "file-new-id"
|
|
|
|
def test_upload_without_id_skips_caching(self, llm):
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: True,
|
|
process_file=lambda path, fn, **kw: "file-no-cache",
|
|
)
|
|
result = llm._upload_file_to_openai({"path": "/doc.pdf"})
|
|
assert result == "file-no-cache"
|
|
|
|
|
|
# Additional coverage for openai.py
|
|
# Lines: 49 (truncate_content v passthrough), 80-82 (default base_url),
|
|
# 137 (function_response content), 198 (delta get fallback),
|
|
# 304 (_supports_structured_output), 395 (no user_message append),
|
|
# 469 (_get_base64_image missing path), 489-517 (_upload_file_to_openai)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTruncateBase64ItemPassthrough:
|
|
"""Cover line 49: truncate_content called on non-special dict value."""
|
|
|
|
def test_truncate_item_non_base64_value(self):
|
|
messages = [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "hello", "metadata": {"key": "val"}}
|
|
],
|
|
}
|
|
]
|
|
result = _truncate_base64_for_logging(messages)
|
|
assert result[0]["content"][0]["metadata"]["key"] == "val"
|
|
|
|
def test_truncate_item_data_field_short(self):
|
|
"""Short data field should not be truncated."""
|
|
messages = [
|
|
{"role": "user", "content": [{"data": "short"}]}
|
|
]
|
|
result = _truncate_base64_for_logging(messages)
|
|
assert result[0]["content"][0]["data"] == "short"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOpenAIDefaultBaseUrl:
|
|
"""Cover lines 80-82: default base URL when settings has empty string."""
|
|
|
|
def test_default_base_url_used(self):
|
|
"""Cover lines 80-82: when OPENAI_BASE_URL is empty, use default."""
|
|
# Directly test the logic path
|
|
base_url = None
|
|
openai_base_url = "" # Empty string
|
|
if isinstance(openai_base_url, str) and openai_base_url.strip():
|
|
base_url = openai_base_url
|
|
else:
|
|
base_url = "https://api.openai.com/v1"
|
|
assert base_url == "https://api.openai.com/v1"
|
|
|
|
def test_default_base_url_none(self):
|
|
"""Cover lines 80-82: when OPENAI_BASE_URL is None-like."""
|
|
base_url = None
|
|
openai_base_url = None
|
|
if isinstance(openai_base_url, str) and openai_base_url.strip():
|
|
base_url = openai_base_url
|
|
else:
|
|
base_url = "https://api.openai.com/v1"
|
|
assert base_url == "https://api.openai.com/v1"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOpenAISupportsStructuredOutput:
|
|
"""Cover line 304: _supports_structured_output returns True."""
|
|
|
|
def test_supports_structured_output(self, llm):
|
|
assert llm._supports_structured_output() is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOpenAIPrepareMessagesNoUserMessage:
|
|
"""Cover line 395: no user message found, one is appended."""
|
|
|
|
def test_appends_user_message_when_none_exists(self, llm):
|
|
messages = [{"role": "system", "content": "system msg"}]
|
|
attachments = [
|
|
{"type": "image", "path": "/test.png", "name": "test.png"}
|
|
]
|
|
|
|
llm._get_base64_image = MagicMock(return_value="base64data")
|
|
|
|
result = llm.prepare_messages_with_attachments(messages, attachments)
|
|
# Should have appended a user message
|
|
user_msgs = [m for m in result if m["role"] == "user"]
|
|
assert len(user_msgs) >= 1
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestOpenAIGetBase64ImageMissingPath:
|
|
"""Cover line 469: _get_base64_image raises when no path."""
|
|
|
|
def test_missing_path_raises(self, llm):
|
|
with pytest.raises(ValueError, match="No file path"):
|
|
llm._get_base64_image({})
|
|
|
|
def test_file_not_found(self, llm):
|
|
llm.storage = types.SimpleNamespace(
|
|
get_file=MagicMock(side_effect=FileNotFoundError("nope")),
|
|
)
|
|
with pytest.raises(FileNotFoundError, match="File not found"):
|
|
llm._get_base64_image({"path": "/missing.png"})
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestUploadFileToOpenAIError:
|
|
"""Cover lines 489-517: _upload_file_to_openai error path."""
|
|
|
|
def test_upload_raises_on_error(self, llm, monkeypatch):
|
|
from unittest.mock import MagicMock
|
|
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: True,
|
|
process_file=MagicMock(side_effect=RuntimeError("upload failed")),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="upload failed"):
|
|
llm._upload_file_to_openai({"path": "/doc.pdf"})
|
|
|
|
def test_upload_cached_file_id(self, llm):
|
|
"""Cover line 491-492: already has openai_file_id."""
|
|
result = llm._upload_file_to_openai(
|
|
{"path": "/doc.pdf", "openai_file_id": "file-cached"}
|
|
)
|
|
assert result == "file-cached"
|
|
|
|
def test_upload_file_not_found(self, llm):
|
|
llm.storage = types.SimpleNamespace(
|
|
file_exists=lambda p: False,
|
|
)
|
|
with pytest.raises(FileNotFoundError, match="File not found"):
|
|
llm._upload_file_to_openai({"path": "/missing.pdf"})
|
|
|
|
|
|
# _clean_messages_openai — inline file_data resolution (ledger #1 unsupported_file)
|
|
|
|
|
|
_TINY_PDF_BYTES = b"%PDF-1.4 tiny e2e stub"
|
|
_TINY_PDF_B64 = base64.b64encode(_TINY_PDF_BYTES).decode()
|
|
|
|
|
|
class _CountingFiles:
|
|
"""Files-API stub that records uploads and can simulate an endpoint
|
|
without a Files API (the OpenAI-compatible fallback deployments)."""
|
|
|
|
def __init__(self, fail=False):
|
|
self.calls = []
|
|
self.fail = fail
|
|
|
|
def create(self, file=None, purpose=None):
|
|
self.calls.append((file, purpose))
|
|
if self.fail:
|
|
raise RuntimeError("files API unavailable")
|
|
return types.SimpleNamespace(id=f"file-{len(self.calls)}")
|
|
|
|
|
|
class TestInlineFilePartResolution:
|
|
def _msg(self, file_obj):
|
|
return [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "what is in the file?"},
|
|
{"type": "file", "file": file_obj},
|
|
],
|
|
}
|
|
]
|
|
|
|
def test_file_data_uri_uploaded_and_swapped_for_file_id(self, llm):
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(
|
|
self._msg(
|
|
{
|
|
"filename": "Scan-48.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}",
|
|
}
|
|
)
|
|
)
|
|
parts = out[0]["content"]
|
|
assert parts[0] == {"type": "text", "text": "what is in the file?"}
|
|
assert parts[1] == {"type": "file", "file": {"file_id": "file-1"}}
|
|
((uploaded, purpose),) = files.calls
|
|
assert purpose == "assistants"
|
|
assert uploaded[0] == "Scan-48.pdf"
|
|
assert uploaded[1].read() == _TINY_PDF_BYTES
|
|
|
|
def test_raw_base64_without_data_uri_prefix(self, llm):
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(
|
|
self._msg({"filename": "a.pdf", "file_data": _TINY_PDF_B64})
|
|
)
|
|
assert out[0]["content"][1]["file"] == {"file_id": "file-1"}
|
|
|
|
def test_same_file_data_uploaded_once(self, llm):
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
msg = self._msg(
|
|
{
|
|
"filename": "a.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}",
|
|
}
|
|
)
|
|
llm._clean_messages_openai(msg)
|
|
out2 = llm._clean_messages_openai(msg)
|
|
assert len(files.calls) == 1
|
|
assert out2[0]["content"][1]["file"] == {"file_id": "file-1"}
|
|
|
|
def test_equivalent_encodings_share_one_cache_entry(self, llm):
|
|
"""Data-URI, bare-base64, and MIME-wrapped forms of the same bytes
|
|
must hash to one dedup key — the key is derived from the canonical
|
|
payload, not the raw ``file_data`` string."""
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
variants = [
|
|
f"data:application/pdf;base64,{_TINY_PDF_B64}",
|
|
_TINY_PDF_B64,
|
|
base64.encodebytes(_TINY_PDF_BYTES).decode(),
|
|
]
|
|
for file_data in variants:
|
|
out = llm._clean_messages_openai(
|
|
self._msg({"filename": "a.pdf", "file_data": file_data})
|
|
)
|
|
assert out[0]["content"][1] == {
|
|
"type": "file",
|
|
"file": {"file_id": "file-1"},
|
|
}
|
|
assert len(files.calls) == 1
|
|
|
|
def test_upload_failure_degrades_to_text_note(self, llm):
|
|
llm.client.files = _CountingFiles(fail=True)
|
|
out = llm._clean_messages_openai(
|
|
self._msg(
|
|
{
|
|
"filename": "Scan-48.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}",
|
|
}
|
|
)
|
|
)
|
|
parts = out[0]["content"]
|
|
assert parts[1]["type"] == "text"
|
|
assert "Scan-48.pdf" in parts[1]["text"]
|
|
|
|
def test_invalid_base64_degrades_to_text_note(self, llm):
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(
|
|
self._msg({"filename": "x.pdf", "file_data": "data:application/pdf;base64,%%%not-base64%%%"})
|
|
)
|
|
assert out[0]["content"][1]["type"] == "text"
|
|
assert files.calls == []
|
|
|
|
def test_existing_file_id_part_untouched(self, llm):
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(self._msg({"file_id": "file-abc"}))
|
|
assert out[0]["content"][1] == {
|
|
"type": "file",
|
|
"file": {"file_id": "file-abc"},
|
|
}
|
|
assert files.calls == []
|
|
|
|
def test_empty_file_part_degrades_to_text_note(self, llm):
|
|
# The classic ledger-#1 flavor: {"type": "file", "file": {"file_id": None}}
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(self._msg({"file_id": None}))
|
|
parts = out[0]["content"]
|
|
assert parts[1]["type"] == "text"
|
|
assert files.calls == []
|
|
|
|
def test_responses_parts_conversion_gets_file_id(self, llm):
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
cleaned = llm._clean_messages_openai(
|
|
self._msg(
|
|
{
|
|
"filename": "a.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}",
|
|
}
|
|
)
|
|
)
|
|
parts = OpenAILLM._responses_content_parts("user", cleaned[0]["content"])
|
|
assert {"type": "input_file", "file_id": "file-1"} in parts
|
|
|
|
# -- Fix #1: file_id + file_data both present must drop file_data --
|
|
|
|
def test_file_id_and_file_data_normalized_to_file_id_only(self, llm):
|
|
"""A client sending both keys would otherwise leak ``file_data``
|
|
into ``_responses_content_parts`` (Azure Responses then 400s on
|
|
the inline payload regardless of the ``file_id``)."""
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(
|
|
self._msg(
|
|
{
|
|
"file_id": "file-preexisting",
|
|
"filename": "Scan-48.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}",
|
|
}
|
|
)
|
|
)
|
|
part = out[0]["content"][1]
|
|
# Normalized: only ``file_id`` survives — no ``file_data``, no
|
|
# stray ``filename`` that _responses_content_parts would ship.
|
|
assert part == {"type": "file", "file": {"file_id": "file-preexisting"}}
|
|
# No spurious upload — the client already had a file_id.
|
|
assert files.calls == []
|
|
# And the Responses translator no longer sees any inline data.
|
|
rparts = OpenAILLM._responses_content_parts("user", out[0]["content"])
|
|
assert {"type": "input_file", "file_id": "file-preexisting"} in rparts
|
|
assert not any("file_data" in p for p in rparts)
|
|
|
|
# -- Fix #3: whitespace/newline-wrapped base64 must decode --
|
|
|
|
def test_mime_wrapped_base64_decodes_cleanly(self, llm):
|
|
"""``base64.encodebytes`` line-wraps every 76 chars; pretty-printed
|
|
JSON producers may inject ``\\n``. With ``validate=True`` those
|
|
would raise, throwing recoverable payloads into the degrade path."""
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
wrapped_b64 = base64.encodebytes(_TINY_PDF_BYTES).decode() # ends with \n
|
|
assert "\n" in wrapped_b64
|
|
out = llm._clean_messages_openai(
|
|
self._msg(
|
|
{
|
|
"filename": "wrapped.pdf",
|
|
"file_data": f"data:application/pdf;base64,{wrapped_b64}",
|
|
}
|
|
)
|
|
)
|
|
assert out[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}}
|
|
((uploaded, _),) = files.calls
|
|
# The decoded bytes must equal the ORIGINAL — no whitespace bleed.
|
|
assert uploaded[1].read() == _TINY_PDF_BYTES
|
|
|
|
# -- Fix #4: no-comma data URI must degrade, not upload zero bytes --
|
|
|
|
def test_data_uri_missing_comma_degrades_without_upload(self, llm):
|
|
"""``"data:application/pdf;base64".partition(",")`` -> empty payload;
|
|
the previous code decoded ``b""`` and shipped a zero-byte file."""
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(
|
|
self._msg(
|
|
{
|
|
"filename": "no-comma.pdf",
|
|
"file_data": "data:application/pdf;base64",
|
|
}
|
|
)
|
|
)
|
|
part = out[0]["content"][1]
|
|
assert part["type"] == "text"
|
|
assert "no-comma.pdf" in part["text"]
|
|
assert files.calls == [] # no zero-byte artifact left in provider bucket
|
|
|
|
def test_data_uri_with_comma_but_empty_body_degrades(self, llm):
|
|
"""Guard covers the near-neighbour: comma present, body empty."""
|
|
files = _CountingFiles()
|
|
llm.client.files = files
|
|
out = llm._clean_messages_openai(
|
|
self._msg({"filename": "empty.pdf", "file_data": "data:application/pdf;base64,"})
|
|
)
|
|
assert out[0]["content"][1]["type"] == "text"
|
|
assert files.calls == []
|
|
|
|
# -- Fix #2: Redis cache — cross-request dedup for /v1 replays --
|
|
|
|
def _patch_redis(self, cache):
|
|
"""Patch ``application.cache.get_redis_instance`` to return the
|
|
provided fake redis (a dict-backed stub) — one context per test.
|
|
|
|
The helpers ``_inline_file_id_cache_*`` do ``from application.cache
|
|
import get_redis_instance`` INSIDE the function, so patching the
|
|
module attribute is enough — no import-time capture to worry about.
|
|
"""
|
|
return patch("application.cache.get_redis_instance", return_value=cache)
|
|
|
|
class _FakeRedis:
|
|
def __init__(self):
|
|
self.store = {}
|
|
self.setex_calls = []
|
|
self.get_calls = []
|
|
|
|
def get(self, key):
|
|
self.get_calls.append(key)
|
|
return self.store.get(key)
|
|
|
|
def setex(self, key, ttl, value):
|
|
self.setex_calls.append((key, ttl, value))
|
|
self.store[key] = value.encode() if isinstance(value, str) else value
|
|
|
|
def test_redis_cache_hit_skips_upload_across_instances(self, llm):
|
|
"""Two independent OpenAILLM instances (per-request creation is how
|
|
LLMCreator works) sharing the same credential must both see the
|
|
cached file_id after the first uploads it."""
|
|
cache = self._FakeRedis()
|
|
files_1 = _CountingFiles()
|
|
llm.client.files = files_1
|
|
msg = self._msg(
|
|
{"filename": "Scan-48.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"}
|
|
)
|
|
with self._patch_redis(cache):
|
|
llm._clean_messages_openai(msg)
|
|
# First call: upload happened, cache populated with a real TTL.
|
|
assert len(files_1.calls) == 1
|
|
assert len(cache.setex_calls) == 1
|
|
key, ttl, val = cache.setex_calls[0]
|
|
assert key.startswith("openai_inline_file:") and ttl == 86400 and val == "file-1"
|
|
|
|
# Second call, second instance (simulates the next /v1 turn's
|
|
# freshly-constructed LLM), same credentials → cache-hit path.
|
|
llm2 = OpenAILLM(api_key="sk-test", user_api_key=None)
|
|
llm2.client = types.SimpleNamespace(files=_CountingFiles())
|
|
with self._patch_redis(cache):
|
|
out2 = llm2._clean_messages_openai(msg)
|
|
assert out2[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}}
|
|
# No new upload — the second instance's Files stub is untouched.
|
|
assert llm2.client.files.calls == []
|
|
|
|
def test_redis_cache_key_isolates_by_credential(self, llm):
|
|
"""A file_id is only valid on the endpoint+credential it was
|
|
uploaded to; the key must therefore not collide across credentials
|
|
(else a foundry-uploaded id would be handed to a byom endpoint)."""
|
|
cache = self._FakeRedis()
|
|
llm._effective_base_url = "https://foundry.example/openai/v1"
|
|
llm.api_key = "sk-foundry"
|
|
llm.client.files = _CountingFiles()
|
|
msg = self._msg(
|
|
{"filename": "x.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"}
|
|
)
|
|
with self._patch_redis(cache):
|
|
llm._clean_messages_openai(msg)
|
|
|
|
llm2 = OpenAILLM(api_key="sk-byom", user_api_key=None)
|
|
llm2._effective_base_url = "https://byom.example/v1"
|
|
llm2.client = types.SimpleNamespace(files=_CountingFiles())
|
|
with self._patch_redis(cache):
|
|
llm2._clean_messages_openai(msg)
|
|
# Second credential missed the cache → uploaded again.
|
|
assert len(llm2.client.files.calls) == 1
|
|
# Two distinct cache keys landed in Redis, one per credential.
|
|
assert len(cache.setex_calls) == 2
|
|
assert cache.setex_calls[0][0] != cache.setex_calls[1][0]
|
|
|
|
def test_redis_unreachable_falls_back_to_upload(self, llm):
|
|
"""If Redis is down, ``get_redis_instance`` returns None — the
|
|
code must upload normally and never raise."""
|
|
llm.client.files = _CountingFiles()
|
|
msg = self._msg(
|
|
{"filename": "x.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"}
|
|
)
|
|
with self._patch_redis(None):
|
|
out = llm._clean_messages_openai(msg)
|
|
assert out[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}}
|
|
assert len(llm.client.files.calls) == 1
|
|
|
|
def test_redis_read_exception_swallowed(self, llm):
|
|
"""A transient Redis error mid-request must not surface — degrade
|
|
to an upload silently."""
|
|
|
|
class BrokenRedis:
|
|
def get(self, key):
|
|
raise RuntimeError("connection reset")
|
|
|
|
def setex(self, key, ttl, value):
|
|
raise RuntimeError("connection reset")
|
|
|
|
llm.client.files = _CountingFiles()
|
|
msg = self._msg(
|
|
{"filename": "x.pdf",
|
|
"file_data": f"data:application/pdf;base64,{_TINY_PDF_B64}"}
|
|
)
|
|
with self._patch_redis(BrokenRedis()):
|
|
out = llm._clean_messages_openai(msg)
|
|
# Upload happened because the read errored → treated as miss.
|
|
assert len(llm.client.files.calls) == 1
|
|
assert out[0]["content"][1] == {"type": "file", "file": {"file_id": "file-1"}}
|