Skip to content

Treating OpenAI responses exclusively as either content or function calls, but they can be both #149

Open
@siavashg

Description

@siavashg

OpenAIAgentModel.request_stream makes an assumption about the response being exclusively text or tool calls, but a single OpenAI response can be both. A response that contains both triggers an uncaught exception in OpenAIStreamTextResponse (see below for examples).

Here we're assuming that a response exclusively contains content or tool_calls:

# the first chunk may only contain `role`, so we iterate until we get either `tool_calls` or `content`
while delta.tool_calls is None and delta.content is None:
try:
next_chunk = await response.__anext__()
except StopAsyncIteration as e:
raise UnexpectedModelBehavior('Streamed response ended without content or tool calls') from e
delta = next_chunk.choices[0].delta
start_cost += _map_cost(next_chunk)
if delta.content is not None:
return OpenAIStreamTextResponse(delta.content, response, timestamp, start_cost)
else:
assert delta.tool_calls is not None, f'Expected delta with tool_calls, got {delta}'
return OpenAIStreamStructuredResponse(
response,
{c.index: c for c in delta.tool_calls},
timestamp,
start_cost,
)

An OpenAI response can contain both. Here's a demonstration using OpenAI's client:

from devtools import debug
from openai import OpenAI

client = OpenAI()

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "parameters": {
                "type": "object",
                "properties": {
                    "location": {"type": "string"},
                    "unit": {"type": "string", "enum": ["c", "f"]},
                },
                "required": ["location", "unit"],
                "additionalProperties": False,
            },
        },
    }
]

completion = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {
            "role": "system",
            "content": "Always tell the user what you are about to do",
        },
        {
            "role": "user",
            "content": "What 1+1 and what's the weather like in Paris today?",
        },
    ],
    tools=tools,
    tool_choice="auto",
)
debug(completion)

Output:

 completion: ChatCompletion(
        id='chatcmpl-Ab71YFy1O6SNnnV6uDIBov3Rn8Sk0',
        choices=[
            Choice(
                finish_reason='tool_calls',
                index=0,
                logprobs=None,
                message=ChatCompletionMessage(
                    content=(
                        '1 + 1 equals 2. \n'
                        '\n'
                        'Now, I will look up the current weather in Paris.'
                    ),
                    refusal=None,
                    role='assistant',
                    audio=None,
                    function_call=None,
                    tool_calls=[
                        ChatCompletionMessageToolCall(
                            id='call_Ubvr33St36ChbOUbLMQNy2Ot',
                            function=Function(
                                arguments='{"location":"Paris","unit":"c"}',
                                name='get_weather',
                            ),
                            type='function',
                        ),
                    ],
                ),
            ),
        ],
        created=1733408500,
        model='gpt-4o-2024-08-06',
        object='chat.completion',
        service_tier=None,
        system_fingerprint='fp_7f6be3efb0',
        usage=CompletionUsage(
            completion_tokens=40,
            prompt_tokens=72,
            total_tokens=112,
            completion_tokens_details=CompletionTokensDetails(
                accepted_prediction_tokens=0,
                audio_tokens=0,
                reasoning_tokens=0,
                rejected_prediction_tokens=0,
            ),
            prompt_tokens_details=PromptTokensDetails(
                audio_tokens=0,
                cached_tokens=0,
            ),
        ),
    ) (ChatCompletion)

The equivalent Pydantic AI example will raise an exception:

from __future__ import annotations as _annotations

import asyncio
from typing import Any

from devtools import debug
from pydantic_ai import Agent

weather_agent = Agent(
    "openai:gpt-4o",
    system_prompt="Always tell the user what you are about to do",
)


@weather_agent.tool_plain
async def get_weather(location: str, unit: str) -> dict[str, Any]:
    debug(location, unit)
    return {"result": "123"}


async def main():
    prompt = "What 1+1 and what's the weather like in Paris today?"
    async with weather_agent.run_stream(prompt) as result:
        async for text in result.stream(debounce_by=0.01):
            print(text)
        debug(result)


if __name__ == "__main__":
    asyncio.run(main())

Output:

1
1 +
1 + 1 equals
1 + 1 equals 2
1 + 1 equals 2.

Now,
1 + 1 equals 2.

Now, I will
1 + 1 equals 2.

Now, I will get the
1 + 1 equals 2.

Now, I will get the weather information
1 + 1 equals 2.

Now, I will get the weather information for Paris
1 + 1 equals 2.

Now, I will get the weather information for Paris today.
Traceback (most recent call last):
  File "/tmp/pydantic-ai/weather.py", line 30, in <module>
    asyncio.run(main())
  File "/Users/siavash/.local/share/uv/python/cpython-3.11.6-macos-aarch64-none/lib/python3.11/asyncio/runners.py", line 190, in run
    return runner.run(main)
           ^^^^^^^^^^^^^^^^
  File "/Users/siavash/.local/share/uv/python/cpython-3.11.6-macos-aarch64-none/lib/python3.11/asyncio/runners.py", line 118, in run
    return self._loop.run_until_complete(task)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/siavash/.local/share/uv/python/cpython-3.11.6-macos-aarch64-none/lib/python3.11/asyncio/base_events.py", line 653, in run_until_complete
    return future.result()
           ^^^^^^^^^^^^^^^
  File "/tmp/pydantic-ai/weather.py", line 24, in main
    async for text in result.stream(debounce_by=0.01):
  File "/tmp/pydantic-ai/.venv/lib/python3.11/site-packages/pydantic_ai/result.py", line 152, in stream
    async for text in self.stream_text(debounce_by=debounce_by):
  File "/tmp/pydantic-ai/.venv/lib/python3.11/site-packages/pydantic_ai/result.py", line 191, in stream_text
    async for _ in group_iter:
  File "/tmp/pydantic-ai/.venv/lib/python3.11/site-packages/pydantic_ai/_utils.py", line 198, in async_iter_groups
    item = done.pop().result()
           ^^^^^^^^^^^^^^^^^^^
  File "/tmp/pydantic-ai/.venv/lib/python3.11/site-packages/pydantic_ai/models/openai.py", line 286, in __anext__
    assert choice.delta.content is not None, f'Expected delta with content, invalid chunk: {chunk!r}'
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: Expected delta with content, invalid chunk: ChatCompletionChunk(id='chatcmpl-Ab7Am8lOPLxVN1pySDvqpH16EHO38', choices=[Choice(delta=ChoiceDelta(content=None, function_call=None, refusal=None, role=None, tool_calls=[ChoiceDeltaToolCall(index=0, id='call_SObzzp2KWfVzNUruHE5f7e1T', function=ChoiceDeltaToolCallFunction(arguments='', name='get_weather'), type='function')]), finish_reason=None, index=0, logprobs=None)], created=1733409072, model='gpt-4o-2024-08-06', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_7f6be3efb0', usage=None)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions