Skip to content

Commit 5d6ab26

Browse files
tim-beckerDouweM
andauthored
Support logprobs output from Responses API (#3535)
Co-authored-by: Douwe Maan <douwe@pydantic.dev>
1 parent 6bdd39b commit 5d6ab26

File tree

3 files changed

+253
-16
lines changed

3 files changed

+253
-16
lines changed

pydantic_ai_slim/pydantic_ai/models/openai.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
ChatCompletionContentPartTextParam,
6666
chat_completion,
6767
chat_completion_chunk,
68+
chat_completion_token_logprob,
6869
)
6970
from openai.types.chat.chat_completion_content_part_image_param import ImageURL
7071
from openai.types.chat.chat_completion_content_part_input_audio_param import InputAudio
@@ -169,7 +170,11 @@ class OpenAIChatModelSettings(ModelSettings, total=False):
169170
"""
170171

171172
openai_logprobs: bool
172-
"""Include log probabilities in the response."""
173+
"""Include log probabilities in the response.
174+
175+
For Chat models, these will be included in `ModelResponse.provider_details['logprobs']`.
176+
For Responses models, these will be included in the response output parts `TextPart.provider_details['logprobs']`.
177+
"""
173178

174179
openai_top_logprobs: int
175180
"""Include log probabilities of the top n tokens in the response."""
@@ -1157,7 +1162,10 @@ def _process_response( # noqa: C901
11571162
elif isinstance(item, responses.ResponseOutputMessage):
11581163
for content in item.content:
11591164
if isinstance(content, responses.ResponseOutputText): # pragma: no branch
1160-
items.append(TextPart(content.text, id=item.id))
1165+
part_provider_details: dict[str, Any] | None = None
1166+
if content.logprobs:
1167+
part_provider_details = {'logprobs': _map_logprobs(content.logprobs)}
1168+
items.append(TextPart(content.text, id=item.id, provider_details=part_provider_details))
11611169
elif isinstance(item, responses.ResponseFunctionToolCall):
11621170
items.append(
11631171
ToolCallPart(
@@ -1264,7 +1272,7 @@ async def _responses_create(
12641272
model_request_parameters: ModelRequestParameters,
12651273
) -> AsyncStream[responses.ResponseStreamEvent]: ...
12661274

1267-
async def _responses_create(
1275+
async def _responses_create( # noqa: C901
12681276
self,
12691277
messages: list[ModelRequest | ModelResponse],
12701278
stream: bool,
@@ -1323,6 +1331,8 @@ async def _responses_create(
13231331
include.append('code_interpreter_call.outputs')
13241332
if model_settings.get('openai_include_web_search_sources'):
13251333
include.append('web_search_call.action.sources')
1334+
if model_settings.get('openai_logprobs'):
1335+
include.append('message.output_text.logprobs')
13261336

13271337
# When there are no input messages and we're not reusing a previous response,
13281338
# the OpenAI API will reject a request without any input,
@@ -1354,6 +1364,7 @@ async def _responses_create(
13541364
timeout=model_settings.get('timeout', NOT_GIVEN),
13551365
service_tier=model_settings.get('openai_service_tier', OMIT),
13561366
previous_response_id=previous_response_id or OMIT,
1367+
top_logprobs=model_settings.get('openai_top_logprobs', OMIT),
13571368
reasoning=reasoning,
13581369
user=model_settings.get('openai_user', OMIT),
13591370
text=text or OMIT,
@@ -2283,6 +2294,24 @@ def timestamp(self) -> datetime:
22832294
return self._timestamp
22842295

22852296

2297+
# Convert logprobs to a serializable format
2298+
def _map_logprobs(
2299+
logprobs: list[chat_completion_token_logprob.ChatCompletionTokenLogprob]
2300+
| list[responses.response_output_text.Logprob],
2301+
) -> list[dict[str, Any]]:
2302+
return [
2303+
{
2304+
'token': lp.token,
2305+
'bytes': lp.bytes,
2306+
'logprob': lp.logprob,
2307+
'top_logprobs': [
2308+
{'token': tlp.token, 'bytes': tlp.bytes, 'logprob': tlp.logprob} for tlp in lp.top_logprobs
2309+
],
2310+
}
2311+
for lp in logprobs
2312+
]
2313+
2314+
22862315
def _map_usage(
22872316
response: chat.ChatCompletion | ChatCompletionChunk | responses.Response,
22882317
provider: str,
@@ -2331,19 +2360,7 @@ def _map_provider_details(
23312360

23322361
# Add logprobs to vendor_details if available
23332362
if choice.logprobs is not None and choice.logprobs.content:
2334-
# Convert logprobs to a serializable format
2335-
provider_details['logprobs'] = [
2336-
{
2337-
'token': lp.token,
2338-
'bytes': lp.bytes,
2339-
'logprob': lp.logprob,
2340-
'top_logprobs': [
2341-
{'token': tlp.token, 'bytes': tlp.bytes, 'logprob': tlp.logprob} for tlp in lp.top_logprobs
2342-
],
2343-
}
2344-
for lp in choice.logprobs.content
2345-
]
2346-
2363+
provider_details['logprobs'] = _map_logprobs(choice.logprobs.content)
23472364
if raw_finish_reason := choice.finish_reason:
23482365
provider_details['finish_reason'] = raw_finish_reason
23492366

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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+
- '202'
12+
content-type:
13+
- application/json
14+
host:
15+
- api.openai.com
16+
method: POST
17+
parsed_body:
18+
include:
19+
- message.output_text.logprobs
20+
input:
21+
- content: What is the capital of Minas Gerais?
22+
role: user
23+
instructions: You are a helpful assistant.
24+
model: gpt-4o-mini
25+
stream: false
26+
uri: https://api.openai.com/v1/responses
27+
response:
28+
headers:
29+
alt-svc:
30+
- h3=":443"; ma=86400
31+
connection:
32+
- keep-alive
33+
content-length:
34+
- '3981'
35+
content-type:
36+
- application/json
37+
openai-organization:
38+
- pydantic-28gund
39+
openai-processing-ms:
40+
- '2151'
41+
openai-version:
42+
- '2020-10-01'
43+
strict-transport-security:
44+
- max-age=31536000; includeSubDomains; preload
45+
transfer-encoding:
46+
- chunked
47+
parsed_body:
48+
background: false
49+
billing:
50+
payer: developer
51+
created_at: 1764012314
52+
error: null
53+
id: resp_03c6f7a0e7df74a9006924b11a6120819395892ac2d8143b03
54+
incomplete_details: null
55+
instructions: You are a helpful assistant.
56+
max_output_tokens: null
57+
max_tool_calls: null
58+
metadata: {}
59+
model: gpt-4o-mini-2024-07-18
60+
object: response
61+
output:
62+
- content:
63+
- annotations: []
64+
logprobs:
65+
- bytes:
66+
- 84
67+
- 104
68+
- 101
69+
logprob: -0.0
70+
token: The
71+
top_logprobs: []
72+
- bytes:
73+
- 32
74+
- 99
75+
- 97
76+
- 112
77+
- 105
78+
- 116
79+
- 97
80+
- 108
81+
logprob: 0.0
82+
token: ' capital'
83+
top_logprobs: []
84+
- bytes:
85+
- 32
86+
- 111
87+
- 102
88+
logprob: 0.0
89+
token: ' of'
90+
top_logprobs: []
91+
- bytes:
92+
- 32
93+
- 77
94+
- 105
95+
- 110
96+
- 97
97+
- 115
98+
logprob: -0.0
99+
token: ' Minas'
100+
top_logprobs: []
101+
- bytes:
102+
- 32
103+
- 71
104+
- 101
105+
- 114
106+
- 97
107+
- 105
108+
- 115
109+
logprob: -0.0
110+
token: ' Gerais'
111+
top_logprobs: []
112+
- bytes:
113+
- 32
114+
- 105
115+
- 115
116+
logprob: -5.2e-05
117+
token: ' is'
118+
top_logprobs: []
119+
- bytes:
120+
- 32
121+
- 66
122+
- 101
123+
- 108
124+
- 111
125+
logprob: -4.3e-05
126+
token: ' Belo'
127+
top_logprobs: []
128+
- bytes:
129+
- 32
130+
- 72
131+
- 111
132+
- 114
133+
- 105
134+
- 122
135+
- 111
136+
- 110
137+
- 116
138+
- 101
139+
logprob: -2.0e-06
140+
token: ' Horizonte'
141+
top_logprobs: []
142+
- bytes:
143+
- 46
144+
logprob: -0.0
145+
token: .
146+
top_logprobs: []
147+
text: The capital of Minas Gerais is Belo Horizonte.
148+
type: output_text
149+
id: msg_03c6f7a0e7df74a9006924b11adb348193adc4091df13b8d7c
150+
role: assistant
151+
status: completed
152+
type: message
153+
parallel_tool_calls: true
154+
previous_response_id: null
155+
prompt_cache_key: null
156+
prompt_cache_retention: null
157+
reasoning:
158+
effort: null
159+
summary: null
160+
safety_identifier: null
161+
service_tier: default
162+
status: completed
163+
store: true
164+
temperature: 1.0
165+
text:
166+
format:
167+
type: text
168+
verbosity: medium
169+
tool_choice: auto
170+
tools: []
171+
top_logprobs: 0
172+
top_p: 1.0
173+
truncation: disabled
174+
usage:
175+
input_tokens: 25
176+
input_tokens_details:
177+
cached_tokens: 0
178+
output_tokens: 10
179+
output_tokens_details:
180+
reasoning_tokens: 0
181+
total_tokens: 35
182+
user: null
183+
status:
184+
code: 200
185+
message: OK
186+
version: 1

tests/models/test_openai.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2261,6 +2261,40 @@ async def test_openai_instructions_with_logprobs(allow_model_requests: None):
22612261
]
22622262

22632263

2264+
async def test_openai_instructions_with_responses_logprobs(allow_model_requests: None, openai_api_key: str):
2265+
m = OpenAIResponsesModel(
2266+
'gpt-4o-mini',
2267+
provider=OpenAIProvider(api_key=openai_api_key),
2268+
)
2269+
agent = Agent(m, instructions='You are a helpful assistant.')
2270+
result = await agent.run(
2271+
'What is the capital of Minas Gerais?',
2272+
model_settings=OpenAIResponsesModelSettings(openai_logprobs=True),
2273+
)
2274+
messages = result.all_messages()
2275+
response = cast(Any, messages[1])
2276+
text_part = response.parts[0]
2277+
assert hasattr(text_part, 'provider_details')
2278+
assert text_part.provider_details is not None
2279+
assert 'logprobs' in text_part.provider_details
2280+
assert text_part.provider_details['logprobs'] == [
2281+
{'token': 'The', 'logprob': -0.0, 'bytes': [84, 104, 101], 'top_logprobs': []},
2282+
{'token': ' capital', 'logprob': 0.0, 'bytes': [32, 99, 97, 112, 105, 116, 97, 108], 'top_logprobs': []},
2283+
{'token': ' of', 'logprob': 0.0, 'bytes': [32, 111, 102], 'top_logprobs': []},
2284+
{'token': ' Minas', 'logprob': -0.0, 'bytes': [32, 77, 105, 110, 97, 115], 'top_logprobs': []},
2285+
{'token': ' Gerais', 'logprob': -0.0, 'bytes': [32, 71, 101, 114, 97, 105, 115], 'top_logprobs': []},
2286+
{'token': ' is', 'logprob': -5.2e-05, 'bytes': [32, 105, 115], 'top_logprobs': []},
2287+
{'token': ' Belo', 'logprob': -4.3e-05, 'bytes': [32, 66, 101, 108, 111], 'top_logprobs': []},
2288+
{
2289+
'token': ' Horizonte',
2290+
'logprob': -2.0e-06,
2291+
'bytes': [32, 72, 111, 114, 105, 122, 111, 110, 116, 101],
2292+
'top_logprobs': [],
2293+
},
2294+
{'token': '.', 'logprob': -0.0, 'bytes': [46], 'top_logprobs': []},
2295+
]
2296+
2297+
22642298
async def test_openai_web_search_tool_model_not_supported(allow_model_requests: None, openai_api_key: str):
22652299
m = OpenAIChatModel('gpt-4o', provider=OpenAIProvider(api_key=openai_api_key))
22662300
agent = Agent(

0 commit comments

Comments
 (0)