cai/tests/core/test_openai_chatcompletions.py

177 lines
6.1 KiB
Python

from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
from openai import NOT_GIVEN
from openai.types.chat.chat_completion import ChatCompletion, Choice
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_tool_call import (
ChatCompletionMessageToolCall,
Function,
)
from openai.types.completion_usage import CompletionUsage
from openai.types.responses import (
Response,
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseOutputRefusal,
ResponseOutputText,
)
from cai.sdk.agents import (
ModelResponse,
ModelSettings,
ModelTracing,
OpenAIChatCompletionsModel,
OpenAIProvider,
generation_span,
)
from cai.sdk.agents.models.fake_id import FAKE_RESPONSES_ID
import os
cai_model = os.getenv('CAI_MODEL', "qwen2.5:14b")
@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),
)
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(cai_model)
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
# 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.referenceable_id is None
@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(cai_model)
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
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 == 1
assert resp.usage.input_tokens == 5
assert resp.usage.output_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 = ChatCompletionMessageToolCall(
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(cai_model)
resp: ModelResponse = await model.get_response(
system_instructions=None,
input="",
model_settings=ModelSettings(),
tools=[],
output_schema=None,
handoffs=[],
tracing=ModelTracing.DISABLED,
)
# 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}"