979 lines
32 KiB
Python
979 lines
32 KiB
Python
import json
|
|
from collections.abc import AsyncIterator
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import BadRequestError
|
|
from pydantic import BaseModel
|
|
|
|
from src.exceptions import ValidationException
|
|
from src.llm.backends.openai import OpenAIBackend
|
|
from src.utils.representation import PromptRepresentation
|
|
|
|
|
|
def _await_kwargs(mock_method: Any) -> dict[str, Any]:
|
|
await_args = mock_method.await_args
|
|
if await_args is None:
|
|
raise AssertionError("Expected the mocked method to have been awaited")
|
|
return await_args.kwargs
|
|
|
|
|
|
def _bad_request_error() -> BadRequestError:
|
|
"""A 400 like a provider that doesn't support json_schema would return."""
|
|
request = httpx.Request("POST", "https://example.test/v1/chat/completions")
|
|
response = httpx.Response(400, request=request)
|
|
return BadRequestError(
|
|
"response_format json_schema is not supported", response=response, body=None
|
|
)
|
|
|
|
|
|
async def _empty_stream() -> AsyncIterator[Any]:
|
|
chunks: list[Any] = [] # async generator that yields nothing
|
|
for chunk in chunks:
|
|
yield chunk
|
|
|
|
|
|
class _StructuredResponse(BaseModel):
|
|
answer: str
|
|
|
|
|
|
def _structured_create_return(content: str, parsed: Any = None) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
choices=[
|
|
SimpleNamespace(
|
|
finish_reason="stop",
|
|
message=SimpleNamespace(
|
|
content=content,
|
|
parsed=parsed,
|
|
tool_calls=[],
|
|
reasoning_details=[],
|
|
refusal=None,
|
|
),
|
|
)
|
|
],
|
|
usage=SimpleNamespace(
|
|
prompt_tokens=10,
|
|
completion_tokens=5,
|
|
prompt_tokens_details=None,
|
|
),
|
|
)
|
|
|
|
|
|
@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_forwards_provider_params_extra_body() -> None:
|
|
"""Operator-supplied extra_body in provider_params reaches the OpenAI SDK call.
|
|
|
|
This is the escape hatch for OpenAI-compatible proxies that translate to
|
|
other providers (litellm → Vertex AI Anthropic) and need provider-native
|
|
body fields (e.g. Anthropic's `thinking`).
|
|
"""
|
|
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="claude-haiku-4-5",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
extra_params={
|
|
"extra_body": {"thinking": {"type": "enabled", "budget_tokens": 4096}}
|
|
},
|
|
)
|
|
|
|
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"] == {
|
|
"thinking": {"type": "enabled", "budget_tokens": 4096}
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_backend_forwards_provider_params_extra_headers_and_query() -> (
|
|
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="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
extra_params={
|
|
"extra_headers": {"X-Proxy-Route": "vertex"},
|
|
"extra_query": {"trace_id": "abc123"},
|
|
},
|
|
)
|
|
|
|
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_headers"] == {"X-Proxy-Route": "vertex"}
|
|
assert call["extra_query"] == {"trace_id": "abc123"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_backend_operator_extra_body_wins_over_auto_injection() -> None:
|
|
"""When the operator supplies extra_body.reasoning, it must replace the
|
|
value that thinking_budget_tokens would otherwise auto-inject (operator-wins
|
|
shallow merge).
|
|
"""
|
|
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,
|
|
extra_params={
|
|
"extra_body": {"reasoning": {"effort": "high", "max_tokens": 9999}}
|
|
},
|
|
)
|
|
|
|
await_args = client.chat.completions.create.await_args
|
|
if await_args is None:
|
|
raise AssertionError("Expected OpenAI create call")
|
|
call = await_args.kwargs
|
|
# Operator's whole `reasoning` dict replaces Honcho's auto-injected one.
|
|
assert call["extra_body"] == {"reasoning": {"effort": "high", "max_tokens": 9999}}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_backend_rejects_non_mapping_passthrough() -> None:
|
|
"""A non-mapping passthrough (operator misconfiguration) raises a clear
|
|
ValidationException instead of an opaque TypeError deep in the transport.
|
|
"""
|
|
client = Mock()
|
|
client.chat.completions.create = AsyncMock()
|
|
|
|
backend = OpenAIBackend(client)
|
|
with pytest.raises(ValidationException, match=r"provider_params\.extra_headers"):
|
|
await backend.complete(
|
|
model="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
extra_params={"extra_headers": [["X-Foo", "bar"]]},
|
|
)
|
|
|
|
client.chat.completions.create.assert_not_awaited()
|
|
|
|
|
|
@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"
|
|
|
|
|
|
async def test_openai_backend_translates_canonical_any_tool_choice_to_required() -> (
|
|
None
|
|
):
|
|
"""Regression: a Gemini→OpenAI fallback passes canonical "any", which OpenAI
|
|
rejects as an invalid param. The backend must translate it to "required"."""
|
|
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="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
tools=[
|
|
{
|
|
"name": "get_weather",
|
|
"description": "Lookup weather",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
}
|
|
],
|
|
tool_choice="any",
|
|
)
|
|
|
|
assert _await_kwargs(client.chat.completions.create)["tool_choice"] == "required"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("canonical", "expected"),
|
|
[
|
|
("any", "required"),
|
|
("required", "required"),
|
|
("auto", "auto"),
|
|
("none", "none"),
|
|
(None, None),
|
|
("search", {"type": "function", "function": {"name": "search"}}),
|
|
({"name": "search"}, {"type": "function", "function": {"name": "search"}}),
|
|
({"type": "function"}, {"type": "function"}),
|
|
],
|
|
)
|
|
def test_openai_convert_tool_choice(canonical: Any, expected: Any) -> None:
|
|
assert OpenAIBackend._convert_tool_choice(canonical) == expected # pyright: ignore[reportPrivateUsage]
|
|
|
|
|
|
@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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_parsed_none_with_raw_content_repairs() -> None:
|
|
"""parse() returning parsed=None but with raw content repairs that content."""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock(
|
|
return_value=_structured_create_return('{"answer": "ok"}', parsed=None)
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
)
|
|
|
|
assert isinstance(result.content, _StructuredResponse)
|
|
assert result.content.answer == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_parsed_none_returns_refusal() -> None:
|
|
"""parse() returning parsed=None with no content surfaces the refusal."""
|
|
client = Mock()
|
|
response = _structured_create_return("", parsed=None)
|
|
response.choices[0].message.refusal = "I can't help with that"
|
|
client.chat.completions.parse = AsyncMock(return_value=response)
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
)
|
|
|
|
assert result.content == "I can't help with that"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_parsed_none_no_content_raises() -> None:
|
|
"""json_schema with no parsed model, content, or refusal raises so the
|
|
retry/fallback chain engages — it must NOT silently empty like json_object."""
|
|
from src.exceptions import ValidationException
|
|
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock(
|
|
return_value=_structured_create_return("", parsed=None)
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
with pytest.raises(ValidationException):
|
|
await backend.complete(
|
|
model="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_default_mode_uses_parse() -> None:
|
|
"""Default json_schema mode uses parse(), not the json_object path."""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock(
|
|
return_value=_structured_create_return(
|
|
'{"answer": "ok"}', parsed=_StructuredResponse(answer="ok")
|
|
)
|
|
)
|
|
client.chat.completions.create = AsyncMock()
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
)
|
|
|
|
assert client.chat.completions.parse.await_count == 1
|
|
assert client.chat.completions.create.await_count == 0
|
|
parse_call = _await_kwargs(client.chat.completions.parse)
|
|
assert parse_call["response_format"] is _StructuredResponse
|
|
assert isinstance(result.content, _StructuredResponse)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_schema_rejected_returns_empty_without_second_request() -> (
|
|
None
|
|
):
|
|
"""A provider that rejects json_schema (400) returns empty, no second request.
|
|
|
|
Retrying or re-requesting the same shape is pointless (#797), so a
|
|
BadRequestError is swallowed to an empty representation rather than erroring.
|
|
"""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock(side_effect=_bad_request_error())
|
|
client.chat.completions.create = AsyncMock()
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=PromptRepresentation,
|
|
)
|
|
|
|
assert client.chat.completions.parse.await_count == 1
|
|
assert client.chat.completions.create.await_count == 0 # no second request
|
|
assert isinstance(result.content, PromptRepresentation)
|
|
assert result.content.explicit == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_schema_rejected_with_required_fields_does_not_raise() -> (
|
|
None
|
|
):
|
|
"""A json_schema rejection must not raise even when the response model has
|
|
required fields (empty_structured_output() can't build an empty instance) —
|
|
it falls back to empty content instead of escaping the handler."""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock(side_effect=_bad_request_error())
|
|
client.chat.completions.create = AsyncMock()
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse, # has a required field
|
|
)
|
|
|
|
assert client.chat.completions.parse.await_count == 1
|
|
assert client.chat.completions.create.await_count == 0 # no second request
|
|
assert result.content == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
json.JSONDecodeError("Expecting value", "not json", 0),
|
|
ValueError("transient parse glitch"),
|
|
],
|
|
)
|
|
async def test_structured_output_transient_parse_error_propagates_for_retry(
|
|
exc: Exception,
|
|
) -> None:
|
|
"""Non-400 parse failures propagate so the retry/fallback chain can engage.
|
|
|
|
Only a 400 (json_schema rejection) is treated as terminal-empty; a transient
|
|
decode/validation glitch must re-raise so tenacity retries and the fallback
|
|
model gets a chance — not be silently swallowed to empty on the first try.
|
|
"""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock(side_effect=exc)
|
|
client.chat.completions.create = AsyncMock()
|
|
|
|
backend = OpenAIBackend(client)
|
|
with pytest.raises(type(exc)):
|
|
await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=PromptRepresentation,
|
|
)
|
|
|
|
assert client.chat.completions.create.await_count == 0 # no second request
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_object_mode_request_shape() -> None:
|
|
"""json_object mode skips parse(), requests json_object, injects the schema."""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock()
|
|
client.chat.completions.create = AsyncMock(
|
|
return_value=_structured_create_return('{"answer": "ok"}')
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
extra_params={"structured_output_mode": "json_object"},
|
|
)
|
|
|
|
assert client.chat.completions.parse.await_count == 0
|
|
assert client.chat.completions.create.await_count == 1
|
|
call = _await_kwargs(client.chat.completions.create)
|
|
assert call["response_format"] == {"type": "json_object"}
|
|
|
|
system_messages = [m for m in call["messages"] if m["role"] == "system"]
|
|
assert system_messages, "expected a system message carrying the schema"
|
|
system_content = system_messages[0]["content"]
|
|
assert "JSON" in system_content
|
|
assert "answer" in system_content # schema property serialized in
|
|
assert isinstance(result.content, _StructuredResponse)
|
|
assert result.content.answer == "ok"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_object_mode_prompt_representation_schema_shape() -> None:
|
|
"""PromptRepresentation schema hints explicit observations as content objects."""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock()
|
|
client.chat.completions.create = AsyncMock(
|
|
return_value=_structured_create_return(
|
|
'{"explicit": [{"content": "alice likes coffee"}]}'
|
|
)
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=PromptRepresentation,
|
|
extra_params={"structured_output_mode": "json_object"},
|
|
)
|
|
|
|
call = _await_kwargs(client.chat.completions.create)
|
|
system_messages = [m for m in call["messages"] if m["role"] == "system"]
|
|
assert system_messages, "expected a system message carrying the schema"
|
|
system_content = system_messages[0]["content"]
|
|
schema = json.loads(system_content.split("JSON schema:\n", 1)[1])
|
|
explicit_description = schema["properties"]["explicit"]["description"]
|
|
assert '{"content": "The user is 25 years old"}' in explicit_description
|
|
assert "['The user is 25 years old'" not in explicit_description
|
|
assert isinstance(result.content, PromptRepresentation)
|
|
assert result.content.explicit[0].content == "alice likes coffee"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_object_mode_repairs_markdown() -> None:
|
|
"""A provider that ignores json_object and returns prose must not crash —
|
|
PromptRepresentation repairs to an empty representation, not an exception."""
|
|
client = Mock()
|
|
client.chat.completions.parse = AsyncMock()
|
|
client.chat.completions.create = AsyncMock(
|
|
return_value=_structured_create_return(
|
|
"Sure! Here are the facts:\n- the user likes coffee"
|
|
)
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=PromptRepresentation,
|
|
extra_params={"structured_output_mode": "json_object"},
|
|
)
|
|
|
|
assert isinstance(result.content, PromptRepresentation)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_object_empty_content_returns_empty() -> None:
|
|
"""An empty body with no refusal must produce a graceful empty result, not
|
|
raise — matching the json_schema path's behavior on a contentless response.
|
|
"""
|
|
client = Mock()
|
|
client.chat.completions.create = AsyncMock(
|
|
return_value=_structured_create_return("")
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=PromptRepresentation,
|
|
extra_params={"structured_output_mode": "json_object"},
|
|
)
|
|
|
|
assert isinstance(result.content, PromptRepresentation)
|
|
assert result.content.explicit == []
|
|
# Usage from the (empty) response is preserved, not zeroed.
|
|
assert result.input_tokens == 10
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_object_empty_content_required_fields() -> None:
|
|
"""Empty content for a required-field model falls back to empty string content
|
|
instead of raising (empty_structured_output can't build the instance)."""
|
|
client = Mock()
|
|
client.chat.completions.create = AsyncMock(
|
|
return_value=_structured_create_return("")
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
result = await backend.complete(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse, # has a required field
|
|
extra_params={"structured_output_mode": "json_object"},
|
|
)
|
|
|
|
assert result.content == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_structured_output_json_object_mode_does_not_mutate_messages() -> None:
|
|
"""The schema-injection helper must copy, never mutate the caller's list."""
|
|
client = Mock()
|
|
client.chat.completions.create = AsyncMock(
|
|
return_value=_structured_create_return('{"answer": "ok"}')
|
|
)
|
|
|
|
backend = OpenAIBackend(client)
|
|
original_messages = [{"role": "user", "content": "Hello"}]
|
|
await backend.complete(
|
|
model="glm-4.6",
|
|
messages=original_messages,
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
extra_params={"structured_output_mode": "json_object"},
|
|
)
|
|
|
|
assert original_messages == [{"role": "user", "content": "Hello"}]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_structured_output_default_mode_uses_json_schema() -> None:
|
|
"""Streaming in default mode converts the model to a json_schema dict."""
|
|
client = Mock()
|
|
client.chat.completions.create = AsyncMock(return_value=_empty_stream())
|
|
|
|
backend = OpenAIBackend(client)
|
|
async for _ in backend.stream(
|
|
model="gpt-4.1",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
):
|
|
pass
|
|
|
|
call = _await_kwargs(client.chat.completions.create)
|
|
assert call["response_format"]["type"] == "json_schema"
|
|
assert call["response_format"]["json_schema"]["name"] == "_StructuredResponse"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_structured_output_json_object_mode() -> None:
|
|
"""Streaming in json_object mode requests json_object + injects the schema."""
|
|
client = Mock()
|
|
client.chat.completions.create = AsyncMock(return_value=_empty_stream())
|
|
|
|
backend = OpenAIBackend(client)
|
|
async for _ in backend.stream(
|
|
model="glm-4.6",
|
|
messages=[{"role": "user", "content": "Hello"}],
|
|
max_tokens=100,
|
|
response_format=_StructuredResponse,
|
|
extra_params={"structured_output_mode": "json_object"},
|
|
):
|
|
pass
|
|
|
|
call = _await_kwargs(client.chat.completions.create)
|
|
assert call["response_format"] == {"type": "json_object"}
|
|
system_messages = [m for m in call["messages"] if m["role"] == "system"]
|
|
assert system_messages and "JSON" in system_messages[0]["content"]
|