Files
2026-07-13 12:39:17 +08:00

1168 lines
39 KiB
Python

from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import Any, cast
import httpx
import pytest
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, omit
from openai.types.chat.chat_completion import ChatCompletion, Choice, ChoiceLogprobs
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_message import ChatCompletionMessage
from openai.types.chat.chat_completion_message_custom_tool_call import (
ChatCompletionMessageCustomToolCall,
Custom,
)
from openai.types.chat.chat_completion_message_tool_call import ( # type: ignore[attr-defined]
ChatCompletionMessageFunctionToolCall,
Function,
)
from openai.types.chat.chat_completion_token_logprob import (
ChatCompletionTokenLogprob,
TopLogprob,
)
from openai.types.completion_usage import (
CompletionUsage,
PromptTokensDetails,
)
from openai.types.responses import (
Response,
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseOutputRefusal,
ResponseOutputText,
)
from openai.types.shared import Reasoning
from agents import (
Agent,
ModelResponse,
ModelRetryAdviceRequest,
ModelSettings,
ModelTracing,
OpenAIChatCompletionsModel,
OpenAIProvider,
Runner,
__version__,
generation_span,
)
from agents.exceptions import UserError
from agents.models._retry_runtime import provider_managed_retries_disabled
from agents.models.chatcmpl_helpers import HEADERS_OVERRIDE, ChatCmplHelpers
from agents.models.fake_id import FAKE_RESPONSES_ID
def _minimal_chat_completion(content: str = "ok") -> ChatCompletion:
return ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[
Choice(
index=0,
finish_reason="stop",
message=ChatCompletionMessage(role="assistant", content=content),
)
],
)
async def _run_chat_completions_model_with_custom_base_url(
model_settings: ModelSettings | None = None,
) -> dict[str, Any]:
class DummyCompletions:
def __init__(self) -> None:
self.kwargs: dict[str, Any] = {}
async def create(self, **kwargs: Any) -> Any:
self.kwargs = kwargs
return ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[
Choice(
index=0,
finish_reason="stop",
message=ChatCompletionMessage(role="assistant", content="ok"),
)
],
)
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("https://custom.example.test/v1/")
completions = DummyCompletions()
model = OpenAIChatCompletionsModel(
model="gpt-4",
openai_client=DummyClient(completions), # type: ignore[arg-type]
)
agent = Agent(name="test", model=model, model_settings=model_settings or ModelSettings())
await Runner.run(agent, "hi")
return completions.kwargs
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_with_text_message(monkeypatch) -> None:
"""
When the model returns a ChatCompletionMessage with plain text content,
`get_response` should produce a single `ResponseOutputMessage` containing
a `ResponseOutputText` with that content, and a `Usage` populated from
the completion's usage.
"""
msg = ChatCompletionMessage(role="assistant", content="Hello")
choice = Choice(index=0, finish_reason="stop", message=msg)
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
usage=CompletionUsage(
completion_tokens=5,
prompt_tokens=7,
total_tokens=12,
# completion_tokens_details left blank to test default
prompt_tokens_details=PromptTokensDetails.model_validate(
{"cached_tokens": 3, "cache_write_tokens": 4}
),
),
)
async def patched_fetch_response(self, *args, **kwargs):
return chat
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
# Should have produced exactly one output message with one text part
assert isinstance(resp, ModelResponse)
assert len(resp.output) == 1
assert isinstance(resp.output[0], ResponseOutputMessage)
msg_item = resp.output[0]
assert len(msg_item.content) == 1
assert isinstance(msg_item.content[0], ResponseOutputText)
assert msg_item.content[0].text == "Hello"
# Usage should be preserved from underlying ChatCompletion.usage
assert resp.usage.input_tokens == 7
assert resp.usage.output_tokens == 5
assert resp.usage.total_tokens == 12
assert resp.usage.input_tokens_details.cached_tokens == 3
assert getattr(resp.usage.input_tokens_details, "cache_write_tokens", None) == 4
assert resp.usage.output_tokens_details.reasoning_tokens == 0
assert resp.response_id is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize(
("previous_response_id", "conversation_id", "expected_param"),
[
("resp_123", None, "previous_response_id"),
(None, "conv_123", "conversation_id"),
],
)
async def test_get_response_warns_and_ignores_server_managed_conversation_state_by_default(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
previous_response_id: str | None,
conversation_id: str | None,
expected_param: str,
) -> None:
called = False
async def patched_fetch_response(self, *args, **kwargs):
nonlocal called
called = True
return _minimal_chat_completion()
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
caplog.set_level(logging.WARNING, logger="openai.agents")
await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=None,
)
assert expected_param in caplog.text
assert "Ignoring unsupported server-managed conversation state" in caplog.text
assert called is True
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_warns_and_ignores_prompt_by_default(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
captured_prompt: Any = None
async def patched_fetch_response(self, *args, **kwargs):
nonlocal captured_prompt
captured_prompt = kwargs.get("prompt")
return _minimal_chat_completion()
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
caplog.set_level(logging.WARNING, logger="openai.agents")
await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=cast(Any, {"id": "pmpt_123"}),
)
assert "Reusable prompts are only supported by the Responses API" in caplog.text
assert "Ignoring `prompt`" in caplog.text
assert captured_prompt is None
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize(
("previous_response_id", "conversation_id", "expected_param"),
[
("resp_123", None, "previous_response_id"),
(None, "conv_123", "conversation_id"),
],
)
async def test_get_response_rejects_server_managed_conversation_state_in_strict_mode(
monkeypatch: pytest.MonkeyPatch,
previous_response_id: str | None,
conversation_id: str | None,
expected_param: str,
) -> None:
called = False
async def patched_fetch_response(self, *args, **kwargs):
nonlocal called
called = True
raise AssertionError("_fetch_response should not be called")
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(
use_responses=False,
strict_feature_validation=True,
).get_model("gpt-4")
with pytest.raises(UserError, match="server-managed conversation state") as exc_info:
await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=previous_response_id,
conversation_id=conversation_id,
prompt=None,
)
assert expected_param in str(exc_info.value)
assert called is False
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_rejects_prompt_in_strict_mode(monkeypatch) -> None:
async def patched_fetch_response(self, *args, **kwargs):
raise AssertionError("_fetch_response should not run when prompt is unsupported")
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(
use_responses=False,
strict_feature_validation=True,
).get_model("gpt-4")
with pytest.raises(UserError, match="Reusable prompts"):
await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=cast(Any, {"id": "pmpt_123"}),
)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_rejects_non_text_tool_output_in_strict_mode() -> None:
class DummyCompletions:
async def create(self, **kwargs: Any) -> Any:
raise AssertionError("chat.completions.create should not run")
class DummyClient:
def __init__(self) -> None:
self.chat = type("_Chat", (), {"completions": DummyCompletions()})()
self.base_url = httpx.URL("http://fake")
model = OpenAIChatCompletionsModel(
model="gpt-4",
openai_client=DummyClient(), # type: ignore[arg-type]
strict_feature_validation=True,
)
with pytest.raises(UserError, match="cannot be empty or contain only non-text content"):
await model.get_response(
system_instructions=None,
input=[
{
"type": "function_call_output",
"call_id": "call_image",
"output": [
{
"type": "input_image",
"image_url": "https://example.com/image.png",
}
],
}
],
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_warns_and_sends_placeholder_for_non_text_tool_output(
caplog: pytest.LogCaptureFixture,
) -> None:
class DummyCompletions:
def __init__(self) -> None:
self.kwargs: dict[str, Any] = {}
async def create(self, **kwargs: Any) -> Any:
self.kwargs = kwargs
return _minimal_chat_completion()
class DummyClient:
def __init__(self) -> None:
self.completions = DummyCompletions()
self.chat = type("_Chat", (), {"completions": self.completions})()
self.base_url = httpx.URL("http://fake")
client = DummyClient()
model = OpenAIChatCompletionsModel(
model="gpt-4",
openai_client=client, # type: ignore[arg-type]
)
with caplog.at_level(logging.WARNING, logger="openai.agents"):
await model.get_response(
system_instructions=None,
input=[
{
"type": "function_call_output",
"call_id": "call_image",
"output": [
{
"type": "input_image",
"image_url": "https://example.com/image.png",
}
],
}
],
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
assert client.completions.kwargs["messages"] == [
{
"role": "tool",
"tool_call_id": "call_image",
"content": "[tool output omitted]",
}
]
assert "Replacing the tool output with a placeholder" in caplog.text
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_attaches_logprobs(monkeypatch) -> None:
msg = ChatCompletionMessage(role="assistant", content="Hi!")
choice = Choice(
index=0,
finish_reason="stop",
message=msg,
logprobs=ChoiceLogprobs(
content=[
ChatCompletionTokenLogprob(
token="Hi",
logprob=-0.5,
bytes=[1],
top_logprobs=[TopLogprob(token="Hi", logprob=-0.5, bytes=[1])],
),
ChatCompletionTokenLogprob(
token="!",
logprob=-0.1,
bytes=[2],
top_logprobs=[TopLogprob(token="!", logprob=-0.1, bytes=[2])],
),
]
),
)
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
usage=None,
)
async def patched_fetch_response(self, *args, **kwargs):
return chat
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
assert len(resp.output) == 1
assert isinstance(resp.output[0], ResponseOutputMessage)
text_part = resp.output[0].content[0]
assert isinstance(text_part, ResponseOutputText)
assert text_part.logprobs is not None
assert [lp.token for lp in text_part.logprobs] == ["Hi", "!"]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_with_refusal(monkeypatch) -> None:
"""
When the model returns a ChatCompletionMessage with a `refusal` instead
of normal `content`, `get_response` should produce a single
`ResponseOutputMessage` containing a `ResponseOutputRefusal` part.
"""
msg = ChatCompletionMessage(role="assistant", refusal="No thanks")
choice = Choice(index=0, finish_reason="stop", message=msg)
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
usage=None,
)
async def patched_fetch_response(self, *args, **kwargs):
return chat
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
assert len(resp.output) == 1
assert isinstance(resp.output[0], ResponseOutputMessage)
refusal_part = resp.output[0].content[0]
assert isinstance(refusal_part, ResponseOutputRefusal)
assert refusal_part.refusal == "No thanks"
# With no usage from the completion, usage defaults to zeros.
assert resp.usage.requests == 0
assert resp.usage.input_tokens == 0
assert resp.usage.output_tokens == 0
assert resp.usage.input_tokens_details.cached_tokens == 0
assert resp.usage.output_tokens_details.reasoning_tokens == 0
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_with_tool_call(monkeypatch) -> None:
"""
If the ChatCompletionMessage includes one or more tool_calls, `get_response`
should append corresponding `ResponseFunctionToolCall` items after the
assistant message item with matching name/arguments.
"""
tool_call = ChatCompletionMessageFunctionToolCall(
id="call-id",
type="function",
function=Function(name="do_thing", arguments="{'x':1}"),
)
msg = ChatCompletionMessage(role="assistant", content="Hi", tool_calls=[tool_call])
choice = Choice(index=0, finish_reason="stop", message=msg)
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
usage=None,
)
async def patched_fetch_response(self, *args, **kwargs):
return chat
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
# Expect a message item followed by a function tool call item.
assert len(resp.output) == 2
assert isinstance(resp.output[0], ResponseOutputMessage)
fn_call_item = resp.output[1]
assert isinstance(fn_call_item, ResponseFunctionToolCall)
assert fn_call_item.call_id == "call-id"
assert fn_call_item.name == "do_thing"
assert fn_call_item.arguments == "{'x':1}"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_rejects_custom_tool_call_in_strict_mode(monkeypatch) -> None:
tool_call = ChatCompletionMessageCustomToolCall(
id="tool1",
type="custom",
custom=Custom(name="raw_tool", input="payload"),
)
msg = ChatCompletionMessage(role="assistant", tool_calls=[tool_call])
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[Choice(index=0, finish_reason="tool_calls", message=msg)],
usage=None,
)
async def patched_fetch_response(self, *args, **kwargs):
return chat
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False, strict_feature_validation=True).get_model("gpt-4")
with pytest.raises(UserError, match="Custom tool calls are not supported"):
await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
def test_get_client_disables_provider_managed_retries_on_runner_retry() -> None:
class DummyChatCompletionsClient:
def __init__(self) -> None:
self.base_url = httpx.URL("https://api.openai.com/v1/")
self.chat = type("ChatNamespace", (), {"completions": object()})()
self.with_options_calls: list[dict[str, Any]] = []
def with_options(self, **kwargs):
self.with_options_calls.append(kwargs)
return self
client = DummyChatCompletionsClient()
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=client) # type: ignore[arg-type]
assert cast(object, model._get_client()) is client
with provider_managed_retries_disabled(True):
assert cast(object, model._get_client()) is client
assert client.with_options_calls == [{"max_retries": 0}]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_with_no_message(monkeypatch) -> None:
"""If the model returns no message, get_response should return an empty output."""
msg = ChatCompletionMessage(role="assistant", content="ignored")
choice = Choice(index=0, finish_reason="content_filter", message=msg)
choice.message = None # type: ignore[assignment]
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
usage=None,
)
async def patched_fetch_response(self, *args, **kwargs):
return chat
monkeypatch.setattr(OpenAIChatCompletionsModel, "_fetch_response", patched_fetch_response)
model = OpenAIProvider(use_responses=False).get_model("gpt-4")
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
assert resp.output == []
@pytest.mark.asyncio
async def test_fetch_response_non_stream(monkeypatch) -> None:
"""
Verify that `_fetch_response` builds the correct OpenAI API call when not
streaming and returns the ChatCompletion object directly. We supply a
dummy ChatCompletion through a stubbed OpenAI client and inspect the
captured kwargs.
"""
# Dummy completions to record kwargs
class DummyCompletions:
def __init__(self) -> None:
self.kwargs: dict[str, Any] = {}
async def create(self, **kwargs: Any) -> Any:
self.kwargs = kwargs
return chat
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("http://fake")
msg = ChatCompletionMessage(role="assistant", content="ignored")
choice = Choice(index=0, finish_reason="stop", message=msg)
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
)
completions = DummyCompletions()
dummy_client = DummyClient(completions)
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=dummy_client) # type: ignore
# Execute the private fetch with a system instruction and simple string input.
with generation_span(disabled=True) as span:
result = await model._fetch_response(
system_instructions="sys",
input="hi",
model_settings=ModelSettings(
reasoning=Reasoning(effort="xhigh"),
prompt_cache_retention="24h",
prompt_cache_options={"mode": "explicit", "ttl": "30m"},
),
tools=[],
output_schema=None,
handoffs=[],
span=span,
tracing=ModelTracing.DISABLED,
stream=False,
)
assert result is chat
# Ensure expected args were passed through to OpenAI client.
kwargs = completions.kwargs
assert kwargs["stream"] is omit
assert kwargs["store"] is omit
assert kwargs["model"] == "gpt-4"
assert kwargs["messages"][0]["role"] == "system"
assert kwargs["messages"][0]["content"] == "sys"
assert kwargs["messages"][1]["role"] == "user"
# Defaults for optional fields become the omit sentinel
assert kwargs["tools"] is omit
assert kwargs["tool_choice"] is omit
assert kwargs["response_format"] is omit
assert kwargs["stream_options"] is omit
assert kwargs["reasoning_effort"] == "xhigh"
assert kwargs["prompt_cache_retention"] == "24h"
assert kwargs["prompt_cache_options"] == {"mode": "explicit", "ttl": "30m"}
def test_chat_completions_warns_once_for_responses_only_reasoning_settings(
caplog: pytest.LogCaptureFixture,
) -> None:
model = OpenAIChatCompletionsModel(
model="gpt-5.6-sol",
openai_client=cast(Any, object()),
)
model_settings = ModelSettings(
reasoning=Reasoning(mode="pro", effort="max", context="all_turns")
)
caplog.set_level(logging.WARNING, logger="openai.agents")
model._handle_unsupported_reasoning_settings(model_settings)
model._handle_unsupported_reasoning_settings(model_settings)
assert caplog.text.count("Ignoring unsupported reasoning settings") == 1
assert "reasoning.mode" in caplog.text
assert "reasoning.context" in caplog.text
def test_chat_completions_rejects_responses_only_reasoning_settings_in_strict_mode() -> None:
model = OpenAIChatCompletionsModel(
model="gpt-5.6-sol",
openai_client=cast(Any, object()),
strict_feature_validation=True,
)
with pytest.raises(UserError, match="reasoning.mode"):
model._handle_unsupported_reasoning_settings(
ModelSettings(reasoning=Reasoning(mode="pro", context="all_turns"))
)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_custom_base_url_prompt_cache_key_uses_model_settings_only() -> None:
default_kwargs = await _run_chat_completions_model_with_custom_base_url()
explicit_kwargs = await _run_chat_completions_model_with_custom_base_url(
model_settings=ModelSettings(extra_args={"prompt_cache_key": "cache-key"})
)
assert "prompt_cache_key" not in default_kwargs
assert explicit_kwargs["prompt_cache_key"] == "cache-key"
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_extra_args_prompt_cache_options_allowed_when_direct_field_is_omitted() -> None:
prompt_cache_options = {"mode": "explicit", "ttl": "30m"}
kwargs = await _run_chat_completions_model_with_custom_base_url(
model_settings=ModelSettings(extra_args={"prompt_cache_options": prompt_cache_options})
)
assert kwargs["prompt_cache_options"] == prompt_cache_options
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_duplicate_prompt_cache_options_rejected() -> None:
with pytest.raises(TypeError, match="multiple values.*prompt_cache_options"):
await _run_chat_completions_model_with_custom_base_url(
model_settings=ModelSettings(
prompt_cache_options={"mode": "explicit", "ttl": "30m"},
extra_args={"prompt_cache_options": {"mode": "implicit"}},
)
)
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_get_response_accepts_raw_chat_completions_image_content() -> None:
"""
Raw Chat Completions content parts should be accepted on the SDK input path
when using the Chat Completions backend.
"""
class DummyCompletions:
def __init__(self) -> None:
self.kwargs: dict[str, Any] = {}
async def create(self, **kwargs: Any) -> Any:
self.kwargs = kwargs
return chat
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("https://api.openai.com/v1/")
msg = ChatCompletionMessage(role="assistant", content="ok")
choice = Choice(index=0, finish_reason="stop", message=msg)
chat = ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
usage=None,
)
completions = DummyCompletions()
dummy_client = DummyClient(completions)
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=dummy_client) # type: ignore[arg-type]
await model.get_response(
system_instructions=None,
input=[
# Cast the fixture because the raw chat-style alias is intentionally outside the
# canonical TypedDict shape that mypy expects for ordinary SDK inputs.
cast(
Any,
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,AAAA",
"detail": "high",
},
},
],
},
)
],
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
prompt=None,
)
assert completions.kwargs["messages"] == [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,AAAA",
"detail": "high",
},
},
],
}
]
@pytest.mark.asyncio
async def test_fetch_response_stream(monkeypatch) -> None:
"""
When `stream=True`, `_fetch_response` should return a bare `Response`
object along with the underlying async stream. The OpenAI client call
should include `stream_options` to request usage-delimited chunks.
"""
async def event_stream() -> AsyncIterator[ChatCompletionChunk]:
if False: # pragma: no cover
yield # pragma: no cover
class DummyCompletions:
def __init__(self) -> None:
self.kwargs: dict[str, Any] = {}
async def create(self, **kwargs: Any) -> Any:
self.kwargs = kwargs
return event_stream()
class DummyClient:
def __init__(self, completions: DummyCompletions) -> None:
self.chat = type("_Chat", (), {"completions": completions})()
self.base_url = httpx.URL("http://fake")
completions = DummyCompletions()
dummy_client = DummyClient(completions)
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=dummy_client) # type: ignore
with generation_span(disabled=True) as span:
response, stream = await model._fetch_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
span=span,
tracing=ModelTracing.DISABLED,
stream=True,
)
# Check OpenAI client was called for streaming
assert completions.kwargs["stream"] is True
assert completions.kwargs["store"] is omit
assert completions.kwargs["stream_options"] is omit
# Response is a proper openai Response
assert isinstance(response, Response)
assert response.id == FAKE_RESPONSES_ID
assert response.model == "gpt-4"
assert response.object == "response"
assert response.output == []
# We returned the async iterator produced by our dummy.
assert hasattr(stream, "__aiter__")
def test_store_param():
"""Should default to True for OpenAI API calls, and False otherwise."""
model_settings = ModelSettings()
client = AsyncOpenAI()
assert ChatCmplHelpers.get_store_param(client, model_settings) is True, (
"Should default to True for OpenAI API calls"
)
model_settings = ModelSettings(store=False)
assert ChatCmplHelpers.get_store_param(client, model_settings) is False, (
"Should respect explicitly set store=False"
)
model_settings = ModelSettings(store=True)
assert ChatCmplHelpers.get_store_param(client, model_settings) is True, (
"Should respect explicitly set store=True"
)
def test_clean_gemini_tool_call_id_removes_thought_suffix() -> None:
assert (
ChatCmplHelpers.clean_gemini_tool_call_id(
"call_123__thought__signature",
model="gemini-2.5-pro",
)
== "call_123"
)
def test_get_retry_advice_uses_openai_headers() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
response = httpx.Response(
429,
request=request,
headers={
"x-should-retry": "true",
"retry-after-ms": "500",
"x-request-id": "req_123",
},
json={"error": {"code": "rate_limit"}},
)
error = APIStatusError(
"rate limited", response=response, body={"error": {"code": "rate_limit"}}
)
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=cast(Any, object()))
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
)
)
assert advice is not None
assert advice.suggested is True
assert advice.retry_after == 0.5
assert advice.replay_safety == "safe"
assert advice.normalized is not None
assert advice.normalized.error_code == "rate_limit"
assert advice.normalized.status_code == 429
assert advice.normalized.request_id == "req_123"
def test_get_retry_advice_keeps_stateful_transport_failures_ambiguous() -> None:
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=cast(Any, object()))
error = APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
)
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety is None
assert advice.normalized is not None
assert advice.normalized.is_network_error is True
def test_get_retry_advice_marks_stateful_http_failures_replay_safe() -> None:
request = httpx.Request("POST", "https://api.openai.com/v1/chat/completions")
response = httpx.Response(
429,
request=request,
json={"error": {"code": "rate_limit"}},
)
error = APIStatusError(
"rate limited", response=response, body={"error": {"code": "rate_limit"}}
)
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=cast(Any, object()))
advice = model.get_retry_advice(
ModelRetryAdviceRequest(
error=error,
attempt=1,
stream=False,
previous_response_id="resp_prev",
)
)
assert advice is not None
assert advice.suggested is True
assert advice.replay_safety == "safe"
assert advice.normalized is not None
assert advice.normalized.status_code == 429
def test_get_client_disables_provider_managed_retries_when_requested() -> None:
class DummyClient:
def __init__(self):
self.calls: list[dict[str, int]] = []
def with_options(self, **kwargs):
self.calls.append(kwargs)
return "retry-client"
client = DummyClient()
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=cast(Any, client))
assert cast(object, model._get_client()) is client
with provider_managed_retries_disabled(True):
assert cast(object, model._get_client()) == "retry-client"
assert client.calls == [{"max_retries": 0}]
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("override_ua", [None, "test_user_agent"])
async def test_user_agent_header_chat_completions(override_ua):
called_kwargs: dict[str, Any] = {}
expected_ua = override_ua or f"Agents/Python {__version__}"
class DummyCompletions:
async def create(self, **kwargs):
nonlocal called_kwargs
called_kwargs = kwargs
msg = ChatCompletionMessage(role="assistant", content="Hello")
choice = Choice(index=0, finish_reason="stop", message=msg)
return ChatCompletion(
id="resp-id",
created=0,
model="fake",
object="chat.completion",
choices=[choice],
usage=None,
)
class DummyChatClient:
def __init__(self):
self.chat = type("_Chat", (), {"completions": DummyCompletions()})()
self.base_url = "https://api.openai.com"
model = OpenAIChatCompletionsModel(model="gpt-4", openai_client=DummyChatClient()) # type: ignore
if override_ua is not None:
token = HEADERS_OVERRIDE.set({"User-Agent": override_ua})
else:
token = None
try:
await model.get_response(
system_instructions=None,
input="hi",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
previous_response_id=None,
conversation_id=None,
)
finally:
if token is not None:
HEADERS_OVERRIDE.reset(token)
assert "extra_headers" in called_kwargs
assert called_kwargs["extra_headers"]["User-Agent"] == expected_ua
client = AsyncOpenAI(base_url="http://www.notopenai.com")
model_settings = ModelSettings()
assert ChatCmplHelpers.get_store_param(client, model_settings) is None, (
"Should default to None for non-OpenAI API calls"
)
model_settings = ModelSettings(store=False)
assert ChatCmplHelpers.get_store_param(client, model_settings) is False, (
"Should respect explicitly set store=False"
)
model_settings = ModelSettings(store=True)
assert ChatCmplHelpers.get_store_param(client, model_settings) is True, (
"Should respect explicitly set store=True"
)