|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import json |
| 4 | +import asyncio |
| 5 | +import contextvars |
4 | 6 | from typing import Any |
5 | 7 |
|
6 | 8 | import httpx2 |
7 | 9 | import pytest |
8 | 10 | from pydantic import BaseModel, ValidationError |
9 | 11 |
|
10 | | -from openai import OpenAI, AsyncOpenAI, omit |
| 12 | +from openai import OpenAI, AsyncOpenAI, omit, _models |
11 | 13 | from openai._types import Omit |
12 | 14 | from openai.types.responses import ParsedResponse |
13 | 15 | from openai.lib.streaming.responses import ResponseStreamEvent |
@@ -309,3 +311,41 @@ async def test_phase_selection_preserves_content(sync: bool, streaming: bool, ph |
309 | 311 | assert content.parsed == (Result(answer="final") if actual.phase == "final_answer" else None) |
310 | 312 | else: |
311 | 313 | assert content.refusal == raw["refusal"] |
| 314 | + |
| 315 | + |
| 316 | +@pytest.mark.parametrize("sync", [True, False], ids=["sync", "async"]) |
| 317 | +@pytest.mark.parametrize("streaming", [False, True], ids=["parse", "stream"]) |
| 318 | +async def test_parsing_reuses_types_across_contexts(sync: bool, streaming: bool) -> None: |
| 319 | + response_types: set[tuple[type, type, type]] = set() |
| 320 | + event_types: set[type] = set() |
| 321 | + |
| 322 | + async def request() -> None: |
| 323 | + if streaming: |
| 324 | + emitted, response = await _stream(sync, _events("Preparing.", '{"answer":"final"}')) |
| 325 | + done = [event for event in emitted if event.type == "response.output_text.done"] |
| 326 | + assert [event.parsed for event in done] == [None, Result(answer="final")] |
| 327 | + event_types.update(type(event) for event in done) |
| 328 | + else: |
| 329 | + response = await _parse(sync, [_message('{"answer":"final"}')]) |
| 330 | + assert response.output_parsed == Result(answer="final") |
| 331 | + message = response.output[-1] |
| 332 | + assert message.type == "message" |
| 333 | + part = message.content[0] |
| 334 | + assert part.type == "output_text" |
| 335 | + assert part.parsed == Result(answer="final") |
| 336 | + response_types.add((type(response), type(message), type(part))) |
| 337 | + |
| 338 | + # Inherited contexts can share Pydantic's generic cache and hide the leak. |
| 339 | + for _ in range(3): |
| 340 | + await contextvars.Context().run(asyncio.create_task, request()) |
| 341 | + |
| 342 | + # Pydantic v1 has no TypeAdapter cache, but still exercises type reuse below. |
| 343 | + cache_info = getattr(getattr(_models, "_CachedTypeAdapter", None), "cache_info", None) |
| 344 | + before = cache_info().currsize if cache_info is not None else None |
| 345 | + for _ in range(10): |
| 346 | + await contextvars.Context().run(asyncio.create_task, request()) |
| 347 | + if cache_info is not None: |
| 348 | + assert cache_info().currsize == before |
| 349 | + assert len(response_types) == 1 |
| 350 | + if streaming: |
| 351 | + assert len(event_types) == 1 |
0 commit comments