honcho/tests/llm/test_backends/test_openai.py

337 lines
10 KiB
Python

from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from src.llm.backends.openai import OpenAIBackend
@pytest.mark.asyncio
async def test_openai_backend_uses_gpt5_params_and_extracts_reasoning() -> None:
client = Mock()
client.chat.completions.create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(
content="Hello from GPT-5",
tool_calls=[],
reasoning_details=[
SimpleNamespace(
content="reasoning summary",
model_dump=lambda: {
"type": "reasoning",
"content": "reasoning summary",
},
)
],
),
)
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=5,
prompt_tokens_details=SimpleNamespace(cached_tokens=4),
),
)
)
backend = OpenAIBackend(client)
result = await backend.complete(
model="gpt-5-mini",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=100,
thinking_effort="high",
)
assert result.content == "Hello from GPT-5"
assert result.thinking_content == "reasoning summary"
assert result.reasoning_details == [
{"type": "reasoning", "content": "reasoning summary"}
]
assert result.cache_read_input_tokens == 4
await_args = client.chat.completions.create.await_args
if await_args is None:
raise AssertionError("Expected OpenAI create call")
call = await_args.kwargs
assert call["model"] == "gpt-5-mini"
assert call["max_completion_tokens"] == 100
assert call["reasoning_effort"] == "high"
assert "max_tokens" not in call
@pytest.mark.asyncio
async def test_openai_backend_passes_thinking_effort_through_for_non_gpt5_models() -> (
None
):
client = Mock()
client.chat.completions.create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(
content="Hello from GPT-4.1",
tool_calls=[],
reasoning_details=[],
),
)
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=5,
prompt_tokens_details=None,
),
)
)
backend = OpenAIBackend(client)
await backend.complete(
model="gpt-4.1",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=100,
thinking_effort="low",
)
await_args = client.chat.completions.create.await_args
if await_args is None:
raise AssertionError("Expected OpenAI create call")
call = await_args.kwargs
assert call["model"] == "gpt-4.1"
assert call["max_tokens"] == 100
assert call["reasoning_effort"] == "low"
@pytest.mark.asyncio
async def test_openai_backend_does_not_treat_proxy_models_with_gpt5_substring_as_gpt5() -> (
None
):
"""Regression: proxy/deployment names containing 'gpt-5' must use `max_tokens`.
Flexible OpenAI-compatible configuration means operators commonly route through
proxies/Azure deployments with IDs like `azure-gpt-5-deployment` or
`my-gpt-5-proxy`. A naive substring check would incorrectly send
`max_completion_tokens` (a GPT-5-only parameter) to those endpoints.
"""
client = Mock()
client.chat.completions.create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(
content="ok",
tool_calls=[],
reasoning_details=[],
),
)
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=5,
prompt_tokens_details=None,
),
)
)
backend = OpenAIBackend(client)
await backend.complete(
model="my-gpt-5-proxy",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=100,
)
await_args = client.chat.completions.create.await_args
if await_args is None:
raise AssertionError("Expected OpenAI create call")
call = await_args.kwargs
assert call["max_tokens"] == 100
assert "max_completion_tokens" not in call
@pytest.mark.asyncio
async def test_openai_backend_passes_thinking_budget_via_extra_body() -> None:
client = Mock()
client.chat.completions.create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(
content="ok",
tool_calls=[],
reasoning_details=[],
),
)
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=5,
prompt_tokens_details=None,
),
)
)
backend = OpenAIBackend(client)
await backend.complete(
model="x-ai/grok-4.1-fast",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=100,
thinking_budget_tokens=256,
)
await_args = client.chat.completions.create.await_args
if await_args is None:
raise AssertionError("Expected OpenAI create call")
call = await_args.kwargs
assert call["extra_body"] == {"reasoning": {"max_tokens": 256}}
@pytest.mark.asyncio
async def test_openai_backend_skips_extra_body_when_thinking_budget_zero() -> None:
client = Mock()
client.chat.completions.create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(
content="ok",
tool_calls=[],
reasoning_details=[],
),
)
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=5,
prompt_tokens_details=None,
),
)
)
backend = OpenAIBackend(client)
await backend.complete(
model="x-ai/grok-4.1-fast",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=100,
thinking_budget_tokens=0,
)
await_args = client.chat.completions.create.await_args
if await_args is None:
raise AssertionError("Expected OpenAI create call")
call = await_args.kwargs
assert "extra_body" not in call
@pytest.mark.asyncio
async def test_openai_backend_converts_anthropic_style_tools() -> None:
client = Mock()
client.chat.completions.create = AsyncMock(
return_value=SimpleNamespace(
choices=[
SimpleNamespace(
finish_reason="stop",
message=SimpleNamespace(
content="Used tools",
tool_calls=[],
reasoning_details=[],
),
)
],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=5,
prompt_tokens_details=None,
),
)
)
backend = OpenAIBackend(client)
await backend.complete(
model="gpt-4.1",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=100,
tools=[
{
"name": "get_weather",
"description": "Lookup weather",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
}
],
tool_choice="required",
)
await_args = client.chat.completions.create.await_args
if await_args is None:
raise AssertionError("Expected OpenAI create call")
call = await_args.kwargs
assert call["tools"] == [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Lookup weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}
]
assert call["tool_choice"] == "required"
@pytest.mark.parametrize(
"model",
[
"gpt-5",
"gpt-5-turbo",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.5-preview",
"o1",
"o1-mini",
"o3",
"o3-mini",
"o4-preview",
],
)
def test_openai_reasoning_models_use_max_completion_tokens(model: str) -> None:
"""Reasoning model families (gpt-5 incl. x.y versions, o1/o3/o4) must send
max_completion_tokens, not max_tokens — OpenAI rejects max_tokens for them
with 400 unsupported_parameter."""
from src.llm.backends.openai import (
_uses_max_completion_tokens, # pyright: ignore[reportPrivateUsage]
)
assert _uses_max_completion_tokens(model) is True
@pytest.mark.parametrize(
"model",
[
"gpt-4.1",
"gpt-4o",
"gpt-4o-mini",
"gpt-3.5-turbo",
"some-proxy-model",
],
)
def test_openai_classic_models_use_max_tokens(model: str) -> None:
"""Non-reasoning OpenAI and OpenAI-compatible proxy models stay on
the classic max_tokens parameter."""
from src.llm.backends.openai import (
_uses_max_completion_tokens, # pyright: ignore[reportPrivateUsage]
)
assert _uses_max_completion_tokens(model) is False