Skip to content

Commit 772af1d

Browse files
authored
validate OpenAI responses (#2226)
1 parent 87871b3 commit 772af1d

File tree

5 files changed

+233
-2
lines changed

5 files changed

+233
-2
lines changed

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from datetime import datetime
99
from typing import Any, Literal, Union, cast, overload
1010

11+
from pydantic import ValidationError
1112
from typing_extensions import assert_never
1213

1314
from pydantic_ai._thinking_part import split_content_into_text_and_thinking
@@ -347,8 +348,19 @@ async def _completions_create(
347348
raise ModelHTTPError(status_code=status_code, model_name=self.model_name, body=e.body) from e
348349
raise # pragma: no cover
349350

350-
def _process_response(self, response: chat.ChatCompletion) -> ModelResponse:
351+
def _process_response(self, response: chat.ChatCompletion | str) -> ModelResponse:
351352
"""Process a non-streamed response, and prepare a message to return."""
353+
# Although the OpenAI SDK claims to return a Pydantic model (`ChatCompletion`) from the chat completions function:
354+
# * it hasn't actually performed validation (presumably they're creating the model with `model_construct` or something?!)
355+
# * if the endpoint returns plain text, the return type is a string
356+
# Thus we validate it fully here.
357+
if not isinstance(response, chat.ChatCompletion):
358+
raise UnexpectedModelBehavior('Invalid response from OpenAI chat completions endpoint, expected JSON data')
359+
360+
try:
361+
response = chat.ChatCompletion.model_validate(response.model_dump())
362+
except ValidationError as e:
363+
raise UnexpectedModelBehavior(f'Invalid response from OpenAI chat completions endpoint: {e}') from e
352364
timestamp = number_to_datetime(response.created)
353365
choice = response.choices[0]
354366
items: list[ModelResponsePart] = []
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
interactions:
2+
- request:
3+
headers:
4+
accept:
5+
- application/json
6+
accept-encoding:
7+
- gzip, deflate
8+
connection:
9+
- keep-alive
10+
content-length:
11+
- '105'
12+
content-type:
13+
- application/json
14+
host:
15+
- demo-endpoints.pydantic.workers.dev
16+
method: POST
17+
parsed_body:
18+
messages:
19+
- content: What is the capital of France?
20+
role: user
21+
model: gpt-4o
22+
stream: false
23+
uri: https://demo-endpoints.pydantic.workers.dev/bin/content-type/application/json/chat/completions
24+
response:
25+
headers:
26+
alt-svc:
27+
- h3=":443"; ma=86400
28+
connection:
29+
- keep-alive
30+
content-length:
31+
- '128'
32+
content-type:
33+
- application/json
34+
nel:
35+
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
36+
report-to:
37+
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=vnOen4x5ThZsFrq57KAufS6JIp6%2FonMEN9WyAWXKhWzx0nNhyrIm3l5ffXE0t9yP69ay6%2Bj8TXT4jmQqDkjFOqlTXQ0lpQa7jkrZpXjuk1iD2hEyEZd5q%2F6ZKddrnPGojfa4%2FOwgp3aw2wf3DFzFZoPWYFhlEA%3D%3D"}],"group":"cf-nel","max_age":604800}'
38+
server-timing:
39+
- cfL4;desc="?proto=TCP&rtt=5088&min_rtt=4528&rtt_var=1666&sent=5&recv=8&lost=0&retrans=0&sent_bytes=2868&recv_bytes=1339&delivery_rate=756649&cwnd=252&unsent_bytes=0&cid=1ee35b7dfe7143b8&ts=51&x=0"
40+
transfer-encoding:
41+
- chunked
42+
vary:
43+
- Accept-Encoding
44+
parsed_body:
45+
contentType: application/json
46+
method: POST
47+
pathname: /bin/content-type/application/json/chat/completions
48+
status:
49+
code: 200
50+
message: OK
51+
version: 1
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
interactions:
2+
- request:
3+
headers:
4+
accept:
5+
- application/json
6+
accept-encoding:
7+
- gzip, deflate
8+
connection:
9+
- keep-alive
10+
content-length:
11+
- '105'
12+
content-type:
13+
- application/json
14+
host:
15+
- demo-endpoints.pydantic.workers.dev
16+
method: POST
17+
parsed_body:
18+
messages:
19+
- content: What is the capital of France?
20+
role: user
21+
model: gpt-4o
22+
stream: false
23+
uri: https://demo-endpoints.pydantic.workers.dev/bin/chat/completions
24+
response:
25+
body:
26+
string: method=POST pathname=/bin/chat/completions Content-Type=text/plain
27+
headers:
28+
alt-svc:
29+
- h3=":443"; ma=86400
30+
connection:
31+
- keep-alive
32+
content-length:
33+
- '66'
34+
content-type:
35+
- text/plain
36+
nel:
37+
- '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}'
38+
report-to:
39+
- '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=JNLqg8RHZTY3qqAmfwzA3vjAJCnVIWrBopVnzEbxZacVCpdlDStUhB%2BnUFpk%2BK51POBOH8s6zKMJkA%2FDNORrbGZiP7MfeOrH5wmiqrw4D2F2L3L8w8GBYioreKodF%2BTsCrbqR0Y6XReZHA86T9IGo94AtnBlQg%3D%3D"}],"group":"cf-nel","max_age":604800}'
40+
server-timing:
41+
- cfL4;desc="?proto=TCP&rtt=24558&min_rtt=23830&rtt_var=9456&sent=5&recv=7&lost=0&retrans=0&sent_bytes=2869&recv_bytes=1309&delivery_rate=175493&cwnd=33&unsent_bytes=0&cid=4087bdc474291a40&ts=53&x=0"
42+
transfer-encoding:
43+
- chunked
44+
vary:
45+
- Accept-Encoding
46+
status:
47+
code: 200
48+
message: OK
49+
version: 1
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
interactions:
2+
- request:
3+
headers:
4+
accept:
5+
- application/json
6+
accept-encoding:
7+
- gzip, deflate
8+
connection:
9+
- keep-alive
10+
content-length:
11+
- '105'
12+
content-type:
13+
- application/json
14+
host:
15+
- api.openai.com
16+
method: POST
17+
parsed_body:
18+
messages:
19+
- content: What is the capital of France?
20+
role: user
21+
model: gpt-4o
22+
stream: false
23+
uri: https://api.openai.com/v1/chat/completions
24+
response:
25+
headers:
26+
access-control-expose-headers:
27+
- X-Request-ID
28+
alt-svc:
29+
- h3=":443"; ma=86400
30+
connection:
31+
- keep-alive
32+
content-length:
33+
- '832'
34+
content-type:
35+
- application/json
36+
openai-organization:
37+
- pydantic-28gund
38+
openai-processing-ms:
39+
- '288'
40+
openai-project:
41+
- proj_wlzE3wrTAwGKSsoZUKNhfDgz
42+
openai-version:
43+
- '2020-10-01'
44+
strict-transport-security:
45+
- max-age=31536000; includeSubDomains; preload
46+
transfer-encoding:
47+
- chunked
48+
parsed_body:
49+
choices:
50+
- finish_reason: stop
51+
index: 0
52+
logprobs: null
53+
message:
54+
annotations: []
55+
content: The capital of France is Paris.
56+
refusal: null
57+
role: assistant
58+
created: 1752720361
59+
id: chatcmpl-Bu8vBIrB8kIWKRyTcpEEPncjhHtMU
60+
model: gpt-4o-2024-08-06
61+
object: chat.completion
62+
service_tier: default
63+
system_fingerprint: fp_a288987b44
64+
usage:
65+
completion_tokens: 7
66+
completion_tokens_details:
67+
accepted_prediction_tokens: 0
68+
audio_tokens: 0
69+
reasoning_tokens: 0
70+
rejected_prediction_tokens: 0
71+
prompt_tokens: 14
72+
prompt_tokens_details:
73+
audio_tokens: 0
74+
cached_tokens: 0
75+
total_tokens: 21
76+
status:
77+
code: 200
78+
message: OK
79+
version: 1

tests/models/test_openai.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from pydantic_ai.settings import ModelSettings
4747
from pydantic_ai.tools import ToolDefinition
4848

49-
from ..conftest import IsDatetime, IsInstance, IsNow, IsStr, raise_if_exception, try_import
49+
from ..conftest import IsDatetime, IsInstance, IsNow, IsStr, TestEnv, raise_if_exception, try_import
5050
from .mock_async_stream import MockAsyncStream
5151

5252
with try_import() as imports_successful:
@@ -2539,3 +2539,43 @@ async def get_user_country() -> str:
25392539
),
25402540
]
25412541
)
2542+
2543+
2544+
async def test_valid_response(env: TestEnv, allow_model_requests: None):
2545+
"""VCR recording is of a valid response."""
2546+
env.set('OPENAI_API_KEY', 'foobar')
2547+
agent = Agent('openai:gpt-4o')
2548+
2549+
result = await agent.run('What is the capital of France?')
2550+
assert result.output == snapshot('The capital of France is Paris.')
2551+
2552+
2553+
async def test_invalid_response(allow_model_requests: None):
2554+
"""VCR recording is of an invalid JSON response."""
2555+
m = OpenAIModel(
2556+
'gpt-4o',
2557+
provider=OpenAIProvider(
2558+
api_key='foobar', base_url='https://demo-endpoints.pydantic.workers.dev/bin/content-type/application/json'
2559+
),
2560+
)
2561+
agent = Agent(m)
2562+
2563+
with pytest.raises(UnexpectedModelBehavior) as exc_info:
2564+
await agent.run('What is the capital of France?')
2565+
assert exc_info.value.message.startswith(
2566+
'Invalid response from OpenAI chat completions endpoint: 5 validation errors for ChatCompletion'
2567+
)
2568+
2569+
2570+
async def test_text_response(allow_model_requests: None):
2571+
"""VCR recording is of a text response."""
2572+
m = OpenAIModel(
2573+
'gpt-4o', provider=OpenAIProvider(api_key='foobar', base_url='https://demo-endpoints.pydantic.workers.dev/bin/')
2574+
)
2575+
agent = Agent(m)
2576+
2577+
with pytest.raises(UnexpectedModelBehavior) as exc_info:
2578+
await agent.run('What is the capital of France?')
2579+
assert exc_info.value.message == snapshot(
2580+
'Invalid response from OpenAI chat completions endpoint, expected JSON data'
2581+
)

0 commit comments

Comments
 (0)