mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-05 06:13:15 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
2400 lines
87 KiB
Python
2400 lines
87 KiB
Python
"""Unit tests for docsgpt/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 logging
|
|
import types
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import BadRequestError
|
|
|
|
from docsgpt.llm.openai import (
|
|
OpenAILLM,
|
|
_is_tools_unsupported_error,
|
|
_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 __init__(self):
|
|
self.created = []
|
|
|
|
def create(self, file=None, purpose=None):
|
|
self.created.append(purpose)
|
|
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 docsgpt.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)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPdfAttachmentNullFileIdCache:
|
|
"""A NULL ``openai_file_id`` column must not read as a cache hit.
|
|
|
|
Attachment dicts come from ``SELECT *`` (``row_to_dict``), so every row
|
|
carries an ``openai_file_id`` key and it is NULL until an upload caches
|
|
one. Testing for key *membership* therefore treated every fresh
|
|
attachment as cached, returned ``None`` without uploading, and — because
|
|
nothing raised — skipped the fallback that inlines the extracted text.
|
|
The user was told their perfectly good PDF "could not be processed".
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _real_pdf_on_disk(self, tmp_path):
|
|
"""The upload path opens the file, so it has to exist."""
|
|
pdf = tmp_path / "contract.pdf"
|
|
pdf.write_bytes(b"%PDF-1.4\ntrailer<</Root 1 0 R>>\n")
|
|
self._pdf_path = str(pdf)
|
|
|
|
def _pg_shaped_attachment(self, **overrides):
|
|
"""What ``_attachment_to_dict`` actually produces for a fresh row."""
|
|
attachment = {
|
|
"id": "att-1",
|
|
"filename": "contract.pdf",
|
|
"path": self._pdf_path,
|
|
"upload_path": self._pdf_path,
|
|
"mime_type": "application/pdf",
|
|
"size": 120_000,
|
|
"content": "REAL EXTRACTED TEXT",
|
|
"token_count": 6748,
|
|
"openai_file_id": None,
|
|
"google_file_uri": None,
|
|
}
|
|
attachment.update(overrides)
|
|
return attachment
|
|
|
|
def test_null_cached_id_still_uploads(self, llm):
|
|
file_id = llm._upload_file_to_openai(self._pg_shaped_attachment())
|
|
assert file_id, "a NULL cached id must not short-circuit the upload"
|
|
assert llm.client.files.created, "files.create was never called"
|
|
|
|
def test_populated_cached_id_short_circuits(self, llm):
|
|
stamped = llm._stamp_file_id("file-cached")
|
|
attachment = self._pg_shaped_attachment(openai_file_id=stamped)
|
|
assert llm._upload_file_to_openai(attachment) == "file-cached"
|
|
assert not llm.client.files.created, "should not re-upload a cached file"
|
|
|
|
def test_cached_id_from_another_endpoint_is_ignored(self, llm):
|
|
"""A file_id only resolves at the endpoint that minted it.
|
|
|
|
``attachments.openai_file_id`` is one global column, so an id cached
|
|
against deployment A would otherwise be replayed against deployment B,
|
|
which answers "No such File object" on every retry — permanently,
|
|
since the row keeps the bad id.
|
|
"""
|
|
attachment = self._pg_shaped_attachment(
|
|
openai_file_id="0123456789abcdef:file-from-elsewhere"
|
|
)
|
|
file_id = llm._upload_file_to_openai(attachment)
|
|
assert file_id == "file_id_uploaded"
|
|
assert llm.client.files.created, "a foreign-scope id must re-upload"
|
|
|
|
def test_legacy_unscoped_cached_id_is_ignored(self, llm):
|
|
"""Rows written before scoping carry a bare id; re-upload and restamp."""
|
|
attachment = self._pg_shaped_attachment(openai_file_id="file-legacy")
|
|
assert llm._upload_file_to_openai(attachment) == "file_id_uploaded"
|
|
assert llm.client.files.created
|
|
|
|
def test_oversized_content_is_truncated_before_inlining(self, llm):
|
|
"""Attachments merge in *after* the context-window check, so nothing
|
|
downstream trims this — a big PDF would blow the whole request."""
|
|
llm._upload_file_to_openai = lambda _attachment: None
|
|
llm.model_id = None # exercise the no-registry default budget
|
|
huge = "word " * 200_000
|
|
attachment = self._pg_shaped_attachment(content=huge)
|
|
|
|
result = llm.prepare_messages_with_attachments(
|
|
[{"role": "user", "content": "summarize"}], [attachment]
|
|
)
|
|
text = next(
|
|
p["text"]
|
|
for p in result[-1]["content"]
|
|
if p.get("type") == "text" and p["text"].startswith("File content:")
|
|
)
|
|
assert len(text) < len(huge)
|
|
assert "Content truncated" in text
|
|
|
|
def test_extracted_text_reaches_the_model_when_upload_yields_no_id(self, llm):
|
|
"""The production failure: no id, no exception, no text, wrong answer."""
|
|
llm._upload_file_to_openai = lambda _attachment: None
|
|
|
|
msgs = [{"role": "user", "content": "summarize the attached pdf"}]
|
|
result = llm.prepare_messages_with_attachments(
|
|
msgs, [self._pg_shaped_attachment()]
|
|
)
|
|
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
assert not any(
|
|
p.get("type") == "file" and not p.get("file", {}).get("file_id")
|
|
for p in user_msg["content"]
|
|
), "must not emit a file part with an empty file_id"
|
|
assert any(
|
|
"REAL EXTRACTED TEXT" in p.get("text", "")
|
|
for p in user_msg["content"]
|
|
if p.get("type") == "text"
|
|
), "extracted content must be inlined rather than dropped"
|
|
|
|
def test_degrade_note_names_the_real_file(self, llm):
|
|
"""Fall back to the note only with no text, and never say 'upload.pdf'."""
|
|
llm._upload_file_to_openai = lambda _attachment: None
|
|
attachment = self._pg_shaped_attachment(content="")
|
|
|
|
msgs = [{"role": "user", "content": "summarize"}]
|
|
result = llm.prepare_messages_with_attachments(msgs, [attachment])
|
|
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
notes = [
|
|
p["text"]
|
|
for p in user_msg["content"]
|
|
if p.get("type") == "text" and "could not be processed" in p.get("text", "")
|
|
]
|
|
assert len(notes) == 1
|
|
assert "contract.pdf" in notes[0]
|
|
assert "upload.pdf" not in notes[0]
|
|
|
|
def test_file_part_carries_only_file_id(self, llm):
|
|
"""Never send ``filename`` next to ``file_id``.
|
|
|
|
Verified against the Azure Foundry deployment: a file part carrying
|
|
both is rejected outright —
|
|
``400 Unknown parameter: 'messages[0].content[1].file.filename'``.
|
|
The upload itself and a ``file_id``-only part both return 200 and the
|
|
model reads the PDF correctly, so the reference is all we may send.
|
|
"""
|
|
msgs = [{"role": "user", "content": "summarize"}]
|
|
result = llm.prepare_messages_with_attachments(
|
|
msgs, [self._pg_shaped_attachment()]
|
|
)
|
|
user_msg = next(m for m in result if m["role"] == "user")
|
|
file_part = next(p for p in user_msg["content"] if p.get("type") == "file")
|
|
assert file_part["file"] == {"file_id": "file_id_uploaded"}
|
|
|
|
|
|
# _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):
|
|
"""A cached openai_file_id short-circuits the upload.
|
|
|
|
The stored value is endpoint-scoped: a bare id is a legacy row and is
|
|
deliberately treated as a miss (see
|
|
``TestPdfAttachmentNullFileIdCache``).
|
|
"""
|
|
result = llm._upload_file_to_openai(
|
|
{"openai_file_id": llm._stamp_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(
|
|
"docsgpt.llm.openai.settings",
|
|
types.SimpleNamespace(
|
|
OPENAI_API_KEY="k",
|
|
API_KEY="k",
|
|
OPENAI_BASE_URL="",
|
|
AZURE_DEPLOYMENT_NAME="dep",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"docsgpt.llm.openai.StorageCreator",
|
|
types.SimpleNamespace(get_storage=lambda: None),
|
|
)
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_openai = MagicMock()
|
|
monkeypatch.setattr("docsgpt.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(
|
|
"docsgpt.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(
|
|
"docsgpt.llm.openai.StorageCreator",
|
|
types.SimpleNamespace(get_storage=lambda: None),
|
|
)
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_openai = MagicMock()
|
|
monkeypatch.setattr("docsgpt.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(
|
|
"docsgpt.llm.openai.settings",
|
|
types.SimpleNamespace(
|
|
OPENAI_API_KEY="k",
|
|
API_KEY="k",
|
|
OPENAI_BASE_URL="",
|
|
AZURE_DEPLOYMENT_NAME="dep",
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"docsgpt.llm.openai.StorageCreator",
|
|
types.SimpleNamespace(get_storage=lambda: None),
|
|
)
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_openai = MagicMock()
|
|
monkeypatch.setattr("docsgpt.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):
|
|
"""Returns the cached openai_file_id when it belongs to this endpoint."""
|
|
result = llm._upload_file_to_openai(
|
|
{"openai_file_id": llm._stamp_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": llm._stamp_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):
|
|
"""Already has an endpoint-scoped openai_file_id."""
|
|
result = llm._upload_file_to_openai(
|
|
{"path": "/doc.pdf", "openai_file_id": llm._stamp_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 ``docsgpt.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 docsgpt.cache
|
|
import get_redis_instance`` INSIDE the function, so patching the
|
|
module attribute is enough — no import-time capture to worry about.
|
|
"""
|
|
return patch("docsgpt.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"}}
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestKeylessConstruction:
|
|
"""openai>=2.53 raises on a falsy api_key at construction.
|
|
|
|
Keyless OpenAI-compatible backends (Ollama, llama.cpp, vLLM) have no
|
|
credential, and pydantic-settings yields "" — not None — for a bare
|
|
`API_KEY=` line in .env, so the empty string must not reach the SDK.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("blank", ["", None])
|
|
def test_llm_accepts_blank_key(self, blank):
|
|
with patch("docsgpt.llm.openai.settings") as mock_settings:
|
|
mock_settings.OPENAI_API_KEY = blank
|
|
mock_settings.API_KEY = blank
|
|
mock_settings.OPENAI_BASE_URL = "http://localhost:11434/v1"
|
|
llm = OpenAILLM(api_key=blank)
|
|
assert llm.api_key == "sk-no-key"
|
|
|
|
@pytest.mark.parametrize("blank", ["", None])
|
|
def test_stt_accepts_blank_key(self, blank):
|
|
from docsgpt.stt.openai_stt import OpenAISTT
|
|
|
|
with patch("docsgpt.stt.openai_stt.settings") as mock_settings:
|
|
mock_settings.OPENAI_API_KEY = blank
|
|
mock_settings.API_KEY = blank
|
|
mock_settings.OPENAI_BASE_URL = "http://localhost:11434/v1"
|
|
mock_settings.OPENAI_STT_MODEL = "whisper-1"
|
|
stt = OpenAISTT(api_key=blank)
|
|
assert stt.api_key == "sk-no-key"
|
|
|
|
|
|
# Tool-calling fallback for endpoints that can't serve tools
|
|
|
|
|
|
VLLM_TOOL_ERROR = (
|
|
'"auto" tool choice requires --enable-auto-tool-choice and '
|
|
"--tool-call-parser to be set"
|
|
)
|
|
|
|
|
|
def _bad_request(message):
|
|
"""Build a real ``openai.BadRequestError`` carrying ``message``."""
|
|
request = httpx.Request("POST", "http://localhost:8000/v1/chat/completions")
|
|
response = httpx.Response(400, request=request)
|
|
return BadRequestError(message, response=response, body={"message": message})
|
|
|
|
|
|
class _RecordingCreate:
|
|
"""``create`` stub that raises on the first call, then succeeds."""
|
|
|
|
def __init__(self, error, result):
|
|
self.error = error
|
|
self.result = result
|
|
self.calls = []
|
|
|
|
def __call__(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
if len(self.calls) == 1 and self.error is not None:
|
|
raise self.error
|
|
return self.result
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestIsToolsUnsupportedError:
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
VLLM_TOOL_ERROR,
|
|
"Tool calling is not supported by this model",
|
|
"This endpoint does not support tools",
|
|
"function calling is unavailable for this deployment",
|
|
"Unsupported parameter: 'tools'",
|
|
],
|
|
)
|
|
def test_matches_tool_messages(self, message):
|
|
assert _is_tools_unsupported_error(_bad_request(message)) is True
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
"This model's maximum context length is 8192 tokens",
|
|
"Invalid value for 'temperature': must be <= 2",
|
|
"Incorrect API key provided",
|
|
],
|
|
)
|
|
def test_ignores_unrelated_messages(self, message):
|
|
assert _is_tools_unsupported_error(_bad_request(message)) is False
|
|
|
|
def test_matches_message_only_present_in_body(self):
|
|
request = httpx.Request("POST", "http://localhost:8000/v1/chat/completions")
|
|
error = BadRequestError(
|
|
"Error code: 400",
|
|
response=httpx.Response(400, request=request),
|
|
body={"message": VLLM_TOOL_ERROR},
|
|
)
|
|
assert _is_tools_unsupported_error(error) is True
|
|
|
|
|
|
def _bad_request_param(message, param):
|
|
"""A 400 that names a request *parameter*, the way OpenAI/Azure do.
|
|
|
|
Mirrors ``openai._base_client._make_status_error_from_response``, which
|
|
interpolates the whole decoded body into the exception's own message --
|
|
the reason a body-repr match sees tool words that the server never said.
|
|
"""
|
|
request = httpx.Request("POST", "http://localhost:8000/v1/chat/completions")
|
|
response = httpx.Response(400, request=request)
|
|
# The SDK unwraps the "error" envelope into ``body`` (so ``error.param``
|
|
# resolves) but keeps the *whole* payload in the message string.
|
|
payload = {"error": {"message": message, "param": param}}
|
|
return BadRequestError(
|
|
f"Error code: 400 - {payload}", response=response, body=payload["error"]
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestToolChoiceIsNotACapabilitySignal:
|
|
"""A rejected ``tool_choice`` is a bad argument, not a missing capability.
|
|
|
|
The SDK interpolates the whole JSON body into ``str(error)``, so matching
|
|
on that made ``"param": "tool_choice"`` look like "this endpoint cannot do
|
|
tools" -- and the v1 translator forwards a client-supplied ``tool_choice``
|
|
verbatim, so any API caller could trigger it.
|
|
"""
|
|
|
|
TOOLS = [{"type": "function", "function": {"name": "t"}}]
|
|
|
|
def test_a_rejected_tool_choice_is_not_a_tools_error(self):
|
|
error = _bad_request_param(
|
|
"Invalid value: 'require'. Supported values are: 'none', 'auto'.",
|
|
"tool_choice",
|
|
)
|
|
|
|
assert _is_tools_unsupported_error(error) is False
|
|
|
|
def test_the_body_repr_alone_does_not_trip_the_match(self):
|
|
# str(error) contains the entire body, including the param name.
|
|
error = _bad_request_param("Unsupported value.", "tool_choice")
|
|
|
|
assert "tool_choice" in str(error)
|
|
assert _is_tools_unsupported_error(error) is False
|
|
|
|
def test_retries_without_tool_choice_but_keeps_the_tools(self, llm):
|
|
create = _RecordingCreate(
|
|
_bad_request_param("Invalid value: 'require'.", "tool_choice"),
|
|
_Response(choices=[_Choice(content="answered")]),
|
|
)
|
|
llm.client.chat.completions.create = create
|
|
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=[{"role": "user", "content": "hi"}],
|
|
stream=False, tools=self.TOOLS, tool_choice="require",
|
|
)
|
|
|
|
assert len(create.calls) == 2
|
|
assert "tool_choice" not in create.calls[1]
|
|
assert create.calls[1]["tools"] == self.TOOLS
|
|
assert llm._supports_tools() is True
|
|
|
|
def test_a_failed_tool_less_retry_does_not_latch(self, llm):
|
|
def _always_400(**kwargs):
|
|
raise _bad_request(VLLM_TOOL_ERROR)
|
|
|
|
llm.client.chat.completions.create = _always_400
|
|
|
|
with pytest.raises(BadRequestError):
|
|
llm._raw_gen(
|
|
llm, model="local-model",
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
stream=False, tools=self.TOOLS,
|
|
)
|
|
|
|
# Nothing proved the endpoint is tool-less, so later turns must retry.
|
|
assert llm._supports_tools() is True
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestToolCallingFallback:
|
|
"""An OpenAI-compatible endpoint without tool support (vLLM started
|
|
without --enable-auto-tool-choice) 400s every chat, because agentless
|
|
chat always sends synthesized default tools. Degrade instead."""
|
|
|
|
TOOLS = [{"type": "function", "function": {"name": "t"}}]
|
|
MSGS = [{"role": "user", "content": "hi"}]
|
|
|
|
def test_raw_gen_retries_once_without_tools(self, llm):
|
|
create = _RecordingCreate(
|
|
_bad_request(VLLM_TOOL_ERROR),
|
|
_Response(choices=[_Choice(content="degraded answer")]),
|
|
)
|
|
llm.client.chat.completions.create = create
|
|
|
|
result = llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False,
|
|
tools=self.TOOLS, tool_choice="auto", parallel_tool_calls=True,
|
|
)
|
|
|
|
assert len(create.calls) == 2
|
|
assert create.calls[0]["tools"] == self.TOOLS
|
|
assert "tools" not in create.calls[1]
|
|
assert "tool_choice" not in create.calls[1]
|
|
assert "parallel_tool_calls" not in create.calls[1]
|
|
# The caller asked for tools, so the tool-shaped return contract holds.
|
|
assert result.message.content == "degraded answer"
|
|
|
|
def test_raw_gen_stream_retries_once_without_tools(self, llm):
|
|
retry_response = _Response(
|
|
lines=[_StreamLine([_Choice(delta="degraded")])]
|
|
)
|
|
create = _RecordingCreate(_bad_request(VLLM_TOOL_ERROR), retry_response)
|
|
llm.client.chat.completions.create = create
|
|
|
|
chunks = list(
|
|
llm._raw_gen_stream(
|
|
llm, model="local-model", messages=self.MSGS, stream=True,
|
|
tools=self.TOOLS, tool_choice="auto",
|
|
)
|
|
)
|
|
|
|
assert chunks == ["degraded"]
|
|
assert len(create.calls) == 2
|
|
assert create.calls[0]["tools"] == self.TOOLS
|
|
assert "tools" not in create.calls[1]
|
|
assert "tool_choice" not in create.calls[1]
|
|
|
|
def test_unrelated_bad_request_propagates(self, llm):
|
|
error = _bad_request("This model's maximum context length is 8192 tokens")
|
|
create = _RecordingCreate(error, _Response(choices=[_Choice(content="x")]))
|
|
llm.client.chat.completions.create = create
|
|
|
|
with pytest.raises(BadRequestError):
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False,
|
|
tools=self.TOOLS,
|
|
)
|
|
|
|
assert len(create.calls) == 1
|
|
assert llm._supports_tools() is True
|
|
|
|
def test_unrelated_bad_request_propagates_from_stream(self, llm):
|
|
error = _bad_request("This model's maximum context length is 8192 tokens")
|
|
create = _RecordingCreate(error, _Response(lines=[]))
|
|
llm.client.chat.completions.create = create
|
|
|
|
with pytest.raises(BadRequestError):
|
|
list(
|
|
llm._raw_gen_stream(
|
|
llm, model="local-model", messages=self.MSGS, stream=True,
|
|
tools=self.TOOLS,
|
|
)
|
|
)
|
|
|
|
assert len(create.calls) == 1
|
|
|
|
def test_tool_error_without_tools_in_request_propagates(self, llm):
|
|
"""No tools were sent, so there is nothing to degrade to."""
|
|
create = _RecordingCreate(
|
|
_bad_request(VLLM_TOOL_ERROR), _Response(choices=[_Choice(content="x")])
|
|
)
|
|
llm.client.chat.completions.create = create
|
|
|
|
with pytest.raises(BadRequestError):
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False, tools=None
|
|
)
|
|
|
|
assert len(create.calls) == 1
|
|
assert llm._supports_tools() is True
|
|
|
|
def test_supports_tools_false_after_rejection(self, llm):
|
|
create = _RecordingCreate(
|
|
_bad_request(VLLM_TOOL_ERROR),
|
|
_Response(choices=[_Choice(content="degraded")]),
|
|
)
|
|
llm.client.chat.completions.create = create
|
|
|
|
assert llm._supports_tools() is True
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False,
|
|
tools=self.TOOLS,
|
|
)
|
|
assert llm._supports_tools() is False
|
|
|
|
def test_later_calls_skip_tools_without_another_round_trip(self, llm):
|
|
create = _RecordingCreate(
|
|
_bad_request(VLLM_TOOL_ERROR),
|
|
_Response(choices=[_Choice(content="degraded")]),
|
|
)
|
|
llm.client.chat.completions.create = create
|
|
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False,
|
|
tools=self.TOOLS,
|
|
)
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False,
|
|
tools=self.TOOLS, tool_choice="auto",
|
|
)
|
|
|
|
# 2 for the first call (reject + retry), 1 for the second.
|
|
assert len(create.calls) == 3
|
|
assert "tools" not in create.calls[2]
|
|
assert "tool_choice" not in create.calls[2]
|
|
|
|
def test_rejection_does_not_leak_to_other_instances(self, llm):
|
|
create = _RecordingCreate(
|
|
_bad_request(VLLM_TOOL_ERROR),
|
|
_Response(choices=[_Choice(content="degraded")]),
|
|
)
|
|
llm.client.chat.completions.create = create
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False,
|
|
tools=self.TOOLS,
|
|
)
|
|
|
|
other = OpenAILLM(api_key="sk-test", user_api_key=None)
|
|
assert other._supports_tools() is True
|
|
|
|
def test_warns_once_with_actionable_message(self, llm, caplog):
|
|
create = _RecordingCreate(
|
|
_bad_request(VLLM_TOOL_ERROR),
|
|
_Response(choices=[_Choice(content="degraded")]),
|
|
)
|
|
llm.client.chat.completions.create = create
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
llm._raw_gen(
|
|
llm, model="local-model", messages=self.MSGS, stream=False,
|
|
tools=self.TOOLS,
|
|
)
|
|
llm._note_tools_rejected("local-model", "second time")
|
|
|
|
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
|
assert len(warnings) == 1
|
|
message = warnings[0].getMessage()
|
|
assert "supports_tools: false" in message
|
|
assert "local-model" in message
|
|
assert "--enable-auto-tool-choice" in message
|