Files
getzep--graphiti/tests/llm_client/test_openai_generic_client.py
wehub-resource-sync 4a19d70af1
Lint with Ruff / ruff (push) Has been cancelled
MCP Server Tests / live-mcp-tests (push) Has been cancelled
Tests / unit-tests (push) Has been cancelled
Tests / database-integration-tests (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Server Tests / live-server-tests (push) Has been cancelled
Pyright Type Check / pyright (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:54 +08:00

171 lines
6.2 KiB
Python

import json
from types import SimpleNamespace
import openai
import pytest
from pydantic import BaseModel
from graphiti_core.llm_client.config import LLMConfig
from graphiti_core.llm_client.errors import EmptyResponseError, RateLimitError
from graphiti_core.llm_client.openai_generic_client import OpenAIGenericClient
from graphiti_core.prompts.models import Message
class DummyChatCompletions:
def __init__(self, content: str = '{}', error: Exception | None = None):
self.create_calls: list[dict] = []
self._content = content
self._error = error
async def create(self, **kwargs):
self.create_calls.append(kwargs)
if self._error is not None:
raise self._error
message = SimpleNamespace(content=self._content)
choice = SimpleNamespace(message=message)
return SimpleNamespace(choices=[choice])
class DummyChat:
def __init__(self, completions: DummyChatCompletions):
self.completions = completions
class DummyClient:
def __init__(self, completions: DummyChatCompletions):
self.chat = DummyChat(completions)
class ResponseModel(BaseModel):
foo: str
def _messages() -> list[Message]:
return [
Message(role='system', content='system message'),
Message(role='user', content='user message'),
]
def _make_client(content: str = '{"foo": "bar"}', error: Exception | None = None, **kwargs):
completions = DummyChatCompletions(content=content, error=error)
client = OpenAIGenericClient(
config=LLMConfig(api_key='test', model='test-model'),
client=DummyClient(completions),
**kwargs,
)
return client, completions
@pytest.mark.asyncio
async def test_defaults_to_json_schema_response_format():
client, completions = _make_client()
await client.generate_response(_messages(), response_model=ResponseModel)
response_format = completions.create_calls[0]['response_format']
assert response_format['type'] == 'json_schema'
assert response_format['json_schema']['name'] == 'ResponseModel'
assert response_format['json_schema']['schema'] == ResponseModel.model_json_schema()
@pytest.mark.asyncio
async def test_json_schema_mode_does_not_inject_schema_into_prompt():
client, completions = _make_client()
messages = _messages()
await client.generate_response(messages, response_model=ResponseModel)
sent_user_content = completions.create_calls[0]['messages'][-1]['content']
assert 'Respond with a JSON object in the following format' not in sent_user_content
@pytest.mark.asyncio
async def test_json_object_mode_uses_json_object_and_injects_schema():
client, completions = _make_client(structured_output_mode='json_object')
await client.generate_response(_messages(), response_model=ResponseModel)
call = completions.create_calls[0]
assert call['response_format'] == {'type': 'json_object'}
# The schema must be injected into the prompt since the API will not enforce it.
sent_user_content = call['messages'][-1]['content']
assert 'Respond with a JSON object in the following format' in sent_user_content
assert json.dumps(ResponseModel.model_json_schema()) in sent_user_content
@pytest.mark.asyncio
async def test_no_response_model_uses_json_object_without_injection():
client, completions = _make_client(content='{"any": "thing"}')
result = await client.generate_response(_messages())
call = completions.create_calls[0]
assert call['response_format'] == {'type': 'json_object'}
assert (
'Respond with a JSON object in the following format' not in call['messages'][-1]['content']
)
assert result == {'any': 'thing'}
@pytest.mark.asyncio
async def test_rate_limit_error_is_translated():
rate_limit = openai.RateLimitError(
message='slow down',
response=SimpleNamespace(status_code=429, headers={}, request=None),
body=None,
)
client, _ = _make_client(error=rate_limit)
# Assert translation at the _generate_response level. Going through generate_response
# would invoke the inherited tenacity retry wrapper (RateLimitError is retryable), which
# adds real backoff sleeps and would make this unit test slow.
with pytest.raises(RateLimitError):
await client._generate_response(_messages(), response_model=ResponseModel)
@pytest.mark.asyncio
async def test_empty_content_raises_empty_response_error():
# Empty body (flaky endpoint / refusal / length cutoff) must raise a clear error,
# not a cryptic JSONDecodeError from json.loads(''). Asserted at _generate_response
# level: EmptyResponseError is retryable, so generate_response would invoke the real
# backoff retry and slow this unit test.
client, _ = _make_client(content='')
with pytest.raises(EmptyResponseError):
await client._generate_response(_messages(), response_model=ResponseModel)
def test_empty_response_error_is_retryable():
# An empty body is treated as a transient provider hiccup (common on local/compatible
# endpoints), so the base retry wrapper retries it rather than failing on first try.
from graphiti_core.llm_client.client import is_server_or_retry_error
assert is_server_or_retry_error(EmptyResponseError('empty')) is True
@pytest.mark.asyncio
async def test_strips_markdown_code_fence_before_parsing():
# Local/compatible models (e.g. Ollama/gemma) often wrap JSON in a ```json fence even
# under a structured response_format; the client must strip it before json.loads.
fenced = '```json\n{"foo": "bar"}\n```'
client, _ = _make_client(content=fenced)
result = await client.generate_response(_messages(), response_model=ResponseModel)
assert result == {'foo': 'bar'}
@pytest.mark.asyncio
async def test_non_retryable_error_is_not_retried():
# The old hand-rolled re-prompt loop is gone. Retry is now delegated to the base
# tenacity wrapper, which only retries transient errors (RateLimitError /
# JSONDecodeError). A non-retryable error (e.g. ValueError) propagates after a
# single create call.
client, completions = _make_client(error=ValueError('bad response'))
with pytest.raises(ValueError):
await client.generate_response(_messages(), response_model=ResponseModel)
assert len(completions.create_calls) == 1