Skip to content

Commit 0100568

Browse files
committed
refactor: openai:responses message construction logic
This refactors openai:responses message encoding so that when multiple pieces of content may be organized into the same message (eg multiple text pieces in one user message), they will be combined rather than emitting separate messages for each content piece. Due to limitations in the api design, this mostly applies to user messages rather than assistant messages.
1 parent a0fad81 commit 0100568

File tree

88 files changed

+6090
-5560
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+6090
-5560
lines changed

python/mirascope/llm/clients/openai/responses/_utils/encode.py

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,24 @@
44
from typing import TypedDict, cast
55

66
from openai import NotGiven
7-
from openai.types import responses as openai_types
8-
from openai.types.responses import response_create_params
9-
from openai.types.responses.easy_input_message_param import EasyInputMessageParam
10-
from openai.types.responses.function_tool_param import FunctionToolParam
11-
from openai.types.responses.response_format_text_json_schema_config_param import (
7+
from openai.types.responses import (
8+
FunctionToolParam,
129
ResponseFormatTextJSONSchemaConfigParam,
13-
)
14-
from openai.types.responses.response_function_tool_call_param import (
1510
ResponseFunctionToolCallParam,
11+
ResponseInputContentParam,
12+
ResponseInputItemParam,
13+
ResponseInputParam,
14+
ResponseInputTextParam,
15+
ResponseTextConfigParam,
16+
ToolChoiceAllowedParam,
17+
ToolChoiceFunctionParam,
18+
response_create_params,
1619
)
20+
from openai.types.responses.easy_input_message_param import EasyInputMessageParam
1721
from openai.types.responses.response_input_param import (
1822
FunctionCallOutput,
19-
ResponseInputItemParam,
20-
ResponseInputParam,
23+
Message as ResponseInputMessageParam,
2124
)
22-
from openai.types.responses.response_text_config_param import ResponseTextConfigParam
23-
from openai.types.responses.tool_choice_allowed_param import ToolChoiceAllowedParam
24-
from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam
2525
from openai.types.shared_params import Reasoning
2626
from openai.types.shared_params.response_format_json_object import (
2727
ResponseFormatJSONObject,
@@ -34,7 +34,7 @@
3434
_utils as _formatting_utils,
3535
resolve_format,
3636
)
37-
from .....messages import Message
37+
from .....messages import AssistantMessage, Message, UserMessage
3838
from .....tools import FORMAT_TOOL_NAME, BaseToolkit, ToolSchema
3939
from ....base import Params, _utils as _base_utils
4040
from ...shared import _utils as _shared_utils
@@ -46,7 +46,7 @@ class ResponseCreateKwargs(TypedDict, total=False):
4646
"""Kwargs to the OpenAI `client.responses.create` method."""
4747

4848
model: ResponsesModel
49-
input: str | openai_types.ResponseInputParam
49+
input: str | ResponseInputParam
5050
instructions: str
5151
temperature: float
5252
max_output_tokens: int
@@ -57,6 +57,88 @@ class ResponseCreateKwargs(TypedDict, total=False):
5757
reasoning: Reasoning | NotGiven
5858

5959

60+
def _encode_user_message(
61+
message: UserMessage,
62+
) -> ResponseInputParam:
63+
current_content: list[ResponseInputContentParam] = []
64+
result: ResponseInputParam = []
65+
66+
def flush_message_content() -> None:
67+
nonlocal current_content
68+
if current_content:
69+
result.append(
70+
ResponseInputMessageParam(
71+
content=current_content, role="user", type="message"
72+
)
73+
)
74+
current_content = []
75+
76+
for part in message.content:
77+
if part.type == "text":
78+
current_content.append(
79+
ResponseInputTextParam(text=part.text, type="input_text")
80+
)
81+
elif part.type == "tool_output":
82+
flush_message_content()
83+
result.append(
84+
FunctionCallOutput(
85+
call_id=part.id,
86+
output=str(part.value),
87+
type="function_call_output",
88+
)
89+
)
90+
else:
91+
raise NotImplementedError(
92+
f"Unsupported user content part type: {part.type}"
93+
)
94+
flush_message_content()
95+
96+
return result
97+
98+
99+
def _encode_assistant_message(
100+
message: AssistantMessage, encode_thoughts: bool
101+
) -> ResponseInputParam:
102+
result: ResponseInputParam = []
103+
104+
# Note: OpenAI does not provide any way to encode multiplie pieces of assistant-generated
105+
# text as adjacent content within the same Message, except as part of
106+
# ResponseOutputMessageParam which requires OpenAI-provided `id` and `status` on the message,
107+
# and `annotations` and `logprobs` on the output text.
108+
# Rather than generating a fake or nonexistent fields and triggering potentially undefined
109+
# server-side behavior, we use `EasyInputMessageParam` for assistant generated text,
110+
# with the caveat that assistant messages containing multiple text parts will be encoded
111+
# as though they are separate messages.
112+
# (It would seem as though the `Message` class in `response_input_param.py` would be suitable,
113+
# especially as it supports the "assistant" role; however attempting to use it triggers a server
114+
# error when text of type input_text is passed as part of an assistant message.)
115+
for part in message.content:
116+
if part.type == "text":
117+
result.append(EasyInputMessageParam(content=part.text, role="assistant"))
118+
elif part.type == "thought":
119+
if encode_thoughts:
120+
result.append(
121+
EasyInputMessageParam(
122+
content="**Thinking:** " + part.thought, role="assistant"
123+
)
124+
)
125+
elif part.type == "tool_call":
126+
result.append(
127+
ResponseFunctionToolCallParam(
128+
call_id=part.id,
129+
name=part.name,
130+
arguments=part.args,
131+
type="function_call",
132+
)
133+
)
134+
else:
135+
raise NotImplementedError(
136+
f"Unsupported assistant content part type: {part.type}"
137+
)
138+
139+
return result
140+
141+
60142
def _encode_message(
61143
message: Message, model_id: OpenAIResponsesModelId, encode_thoughts: bool
62144
) -> ResponseInputParam:
@@ -81,39 +163,10 @@ def _encode_message(
81163
):
82164
return cast(ResponseInputParam, message.raw_message)
83165

84-
result: ResponseInputParam = []
85-
86-
for part in message.content:
87-
if part.type == "text":
88-
result.append(EasyInputMessageParam(role=message.role, content=part.text))
89-
elif part.type == "tool_call":
90-
result.append(
91-
ResponseFunctionToolCallParam(
92-
call_id=part.id,
93-
name=part.name,
94-
arguments=part.args,
95-
type="function_call",
96-
)
97-
)
98-
elif part.type == "tool_output":
99-
result.append(
100-
FunctionCallOutput(
101-
call_id=part.id,
102-
output=str(part.value),
103-
type="function_call_output",
104-
)
105-
)
106-
elif part.type == "thought":
107-
if encode_thoughts:
108-
result.append(
109-
EasyInputMessageParam(
110-
role=message.role, content="**Thinking:** " + part.thought
111-
)
112-
)
113-
else:
114-
raise NotImplementedError(f"Unsupported content part type: {part.type}")
115-
116-
return result
166+
if message.role == "assistant":
167+
return _encode_assistant_message(message, encode_thoughts)
168+
else:
169+
return _encode_user_message(message)
117170

118171

119172
def _convert_tool_to_function_tool_param(tool: ToolSchema) -> FunctionToolParam:

python/tests/e2e/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ def vcr_config() -> VCRConfig:
9090
"x-api-key", # Anthropic API keys
9191
"x-goog-api-key", # Google/Gemini API keys
9292
"anthropic-organization-id", # Anthropic org identifiers
93+
"cookie",
9394
],
9495
"filter_post_data_parameters": [],
9596
}

python/tests/e2e/input/cassettes/test_call_with_image_content/openai_responses_gpt_4o.yaml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -259,19 +259,19 @@ interactions:
259259
access-control-allow-origin:
260260
- '*'
261261
age:
262-
- '73020'
262+
- '11350'
263263
cache-control:
264264
- max-age=31536000
265265
content-length:
266266
- '13444'
267267
content-type:
268268
- image/png
269269
date:
270-
- Mon, 20 Oct 2025 06:12:10 GMT
270+
- Tue, 21 Oct 2025 18:53:57 GMT
271271
etag:
272272
- '"3484-640857429fa00"'
273273
expires:
274-
- Tue, 20 Oct 2026 06:12:10 GMT
274+
- Wed, 21 Oct 2026 18:53:57 GMT
275275
last-modified:
276276
- Mon, 06 Oct 2025 23:03:04 GMT
277277
nel:
@@ -281,30 +281,30 @@ interactions:
281281
- '{ "group": "wm_nel", "max_age": 604800, "endpoints": [{ "url": "https://intake-logging.wikimedia.org/v1/events?stream=w3c.reportingapi.network_error&schema_uri=/w3c/reportingapi/network_error/1.0.0"
282282
}] }'
283283
server:
284-
- mw-web.codfw.main-669ddffffc-bfhxz
284+
- ATS/9.2.11
285285
server-timing:
286-
- cache;desc="hit-front", host;desc="cp4039"
286+
- cache;desc="hit-front", host;desc="cp4042"
287287
set-cookie:
288288
- WMF-Last-Access=21-Oct-2025;Path=/;HttpOnly;secure;Expires=Sat, 22 Nov 2025
289-
00:00:00 GMT
289+
12:00:00 GMT
290290
- WMF-Last-Access-Global=21-Oct-2025;Path=/;Domain=.wikipedia.org;HttpOnly;secure;Expires=Sat,
291-
22 Nov 2025 00:00:00 GMT
292-
- GeoIP=US:WA:Seattle:47.54:-122.28:v4; Path=/; secure; Domain=.wikipedia.org
291+
22 Nov 2025 12:00:00 GMT
292+
- GeoIP=US:WA:Seattle:47.61:-122.31:v4; Path=/; secure; Domain=.wikipedia.org
293293
- NetworkProbeLimit=0.001;Path=/;Secure;SameSite=None;Max-Age=3600
294-
- WMF-Uniq=g7TMOqEykiGknMFmi6eiEQKTAAAAAFvda1aa2ybi2-E2wJcpJ3L6zlGePS86mcRW;Domain=.wikipedia.org;Path=/;HttpOnly;secure;SameSite=None;Expires=Wed,
294+
- WMF-Uniq=mhGFDKvzLcvvOCABiElkSwKTAAAAAFvdc4GkylRfeUD2tDln_3nJKpCYUusFvDlM;Domain=.wikipedia.org;Path=/;HttpOnly;secure;SameSite=None;Expires=Wed,
295295
21 Oct 2026 00:00:00 GMT
296296
strict-transport-security:
297297
- max-age=106384710; includeSubDomains; preload
298298
x-analytics:
299299
- ''
300300
x-cache:
301-
- cp4039 hit, cp4039 hit/170099
301+
- cp4042 hit, cp4042 hit/72743
302302
x-cache-status:
303303
- hit-front
304304
x-client-ip:
305-
- 97.113.225.86
305+
- 71.212.69.193
306306
x-request-id:
307-
- b401c304-7b09-420e-a31b-553a6665c11c
307+
- c0af436a-410c-4eef-9504-92413ad66766
308308
status:
309309
code: 200
310310
message: OK

python/tests/e2e/input/cassettes/test_call_with_params/openai_responses_gpt_4o.yaml

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
interactions:
22
- request:
3-
body: '{"input":[{"role":"user","content":"What is 4200 + 42?"}],"max_output_tokens":500,"model":"gpt-4o","temperature":0.7,"top_p":0.3}'
3+
body: '{"input":[{"content":[{"text":"What is 4200 + 42?","type":"input_text"}],"role":"user","type":"message"}],"max_output_tokens":500,"model":"gpt-4o","temperature":0.7,"top_p":0.3}'
44
headers:
55
accept:
66
- application/json
@@ -9,7 +9,7 @@ interactions:
99
connection:
1010
- keep-alive
1111
content-length:
12-
- '129'
12+
- '177'
1313
content-type:
1414
- application/json
1515
host:
@@ -39,35 +39,35 @@ interactions:
3939
response:
4040
body:
4141
string: !!binary |
42-
H4sIAAAAAAAAA3RU23KjMAx9z1d4/LpNxxDKJb/S2WGEEam3xnZtOdNMJ/++gwkk7LZvoCMdpHMk
43-
vnaMcdXzI+Meg2tFX0KD0LwgNoeXrBSirIe8yQ9ZLUtZZ43o8qauqwybspF5Vwz8aaKw3R+UtNBY
44-
E3COS49A2LcwYVlViiqvSpElLBBQDFONtKPTSNjPRR3I95O30Ux9DaADzmGltTInfmRfO8YY4w4u
45-
6Kf6Hs+orUPPd4xdUzJ6byfMRK1TQJnlK22PBEqHLRrIR0nKmk18hM/WRnKRWrLvmMAXIVaMrNWt
46-
BL1lG22Pemrs5Ghf2H0u8mIv6r0ob2olRn5kr2mQeZzViDGcfvYhP/SymHyAoj5kBVbQFfWha6rE
47-
nFjo4jDxYAhwwjvwk+AJlNYQmntTj41taBc58JPW6pQAxliCRcLX3xtQ25PztvsGSURHxotcCPaL
48-
FTnDjwg6sCIv8me+pl5vT2s191anjiAEFQgMzclTYkriDjxojXprE/k4L5TzeFY2hnbZ2TYZsNro
49-
vB0dtRLkG7bveHnEPEKwZrOOOAzW00PSJHkcR/BL5bqdAQakS6t6NKQGhZtNDejPSmJLatnuAaKe
50-
xeaBrMfHIQhHhx4oprB4rm7RJOqts8H6Ee7vD2amvFm1W8dn9J0Nii7zCvUqjvermnV8s0rOwkey
51-
fAXu3nKyrn1wXKxBl3o8zO8+Gpn2JU2pAnR6+QXEtLnrAMpsLjArn/6PP5z1Omayrr8Xis2o/x52
52-
ln0HfMe7uv8TNVkCfQfzapUwhq3bIxL0QDDRX3fXvwAAAP//AwAhbnZRkQUAAA==
42+
H4sIAAAAAAAAA3RU0Y6jMAx871dEeb3tKVBKS39ldUJuYrq5DUkucaqtVv33E6FAudt9A4892DM2
43+
nxvGuFb8xHjA6FshEZuqVDvcya5WeyHqY3cUu0JiqY7HoqnPRdPtm52UAg/NXhz4y0Dhzr9R0kTj
44+
bMQxLgMCoWphwIpDXYhjVRybjEUCSnGoka73BgnVWHQG+X4JLtmhrw5MxDGsjdH2wk/sc8MYY9zD
45+
DcNQr/CKxnkMfMPYPSdjCG7AbDImB7SdvtIqJNAmrtFIIUnSzq7iPXy0LpFP1JJ7xwzuhZgxcs60
46+
EsyarXcKzdDYxdO2cttSlNVWHLeifqiVGfmJveZBxnFmI/p4+d4HBaoUDx8OZV3KfdE0u64Ypcss
47+
dPOYeTBGuOACfCd4BqWzhHZp6rmxFe0kB37QXJ0TwFpHMEn4+msFGnfxwZ2/QDLRifGqFIL9YFXJ
48+
8E8CE1lVVuVPPqfeH09zNQ/O5I4gRh0JLI3JQ2JO4h4CGINmbROFNC6UD3jVLsV22tk2GzDb6IPr
49+
PbUS5Bu273h7xgJCdHa1jth1LtBT0iB56nsIU+W8nRE6pFurFVrSncbVpkYMVy2xJT1tdwfJjGLz
50+
SC7g8xCEvccAlHJY/Dw8olnUR2edCz0s709m5rxRtUfHVwxnFzXdxhVSOvXLVY06vjktR+ETOT4D
51+
i7ecnG+fHBdz0Oced+N7SFbmfclT6ghnM/0CUt7ceQBtVxdY1C//x5/Oeh4zW6eWQrEa9d/DLoqv
52+
gK94Z/e/oyZHYBawPMwSprh2u0cCBQQD/X1z/wsAAP//AwC8YZJ4kQUAAA==
5353
headers:
5454
CF-RAY:
55-
- 99020953cabc8061-SEA
55+
- 99240b141983a384-SEA
5656
Connection:
5757
- keep-alive
5858
Content-Encoding:
5959
- gzip
6060
Content-Type:
6161
- application/json
6262
Date:
63-
- Fri, 17 Oct 2025 19:00:02 GMT
63+
- Tue, 21 Oct 2025 22:03:10 GMT
6464
Server:
6565
- cloudflare
6666
Set-Cookie:
67-
- __cf_bm=evtYbxmnaQwi5afMTPErkZhDYDpvUkbeaPkpIcJTlWQ-1760727602-1.0.1.1-mqE3NlpND9CEorPhRmFwGKAGN2uqpv2Zbxmc_kHuDnBjmIsUnxQP43rssB88kkirLA50FdlWxO4ukB63YQyDgSOMXV9PUhlGVnT4lj3C36E;
68-
path=/; expires=Fri, 17-Oct-25 19:30:02 GMT; domain=.api.openai.com; HttpOnly;
67+
- __cf_bm=MRHkwPoUWvTHKkm_PjHwq14taZ9HbCNP_AFqmH6MrNs-1761084190-1.0.1.1-ZEXMfnVcFesGmZ_DLJdEgmuNoFWIVWYBus.zFUXsU0lP3bl0J.kz8FoaIoddl7CiEdecJ_oMN8zLy6xIk1Tn2IOW3UNb4KVXLNwCLOHd5yg;
68+
path=/; expires=Tue, 21-Oct-25 22:33:10 GMT; domain=.api.openai.com; HttpOnly;
6969
Secure; SameSite=None
70-
- _cfuvid=Jwpuea6eQDG6bp_P8Cf.nhKpa5JuxqGm5MlxgWVqYy4-1760727602448-0.0.1.1-604800000;
70+
- _cfuvid=LtKS2w9Ws82f46SAMdIBbxU6h9Zs7uPwXvBw.h2gnP8-1761084190024-0.0.1.1-604800000;
7171
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
7272
Strict-Transport-Security:
7373
- max-age=31536000; includeSubDomains; preload
@@ -82,13 +82,13 @@ interactions:
8282
openai-organization:
8383
- sotai-i3ryiz
8484
openai-processing-ms:
85-
- '880'
85+
- '1121'
8686
openai-project:
8787
- proj_2kPLXdwNOjkHt3ifb0aZ4FwU
8888
openai-version:
8989
- '2020-10-01'
9090
x-envoy-upstream-service-time:
91-
- '883'
91+
- '1127'
9292
x-ratelimit-limit-requests:
9393
- '5000'
9494
x-ratelimit-limit-tokens:
@@ -102,7 +102,7 @@ interactions:
102102
x-ratelimit-reset-tokens:
103103
- 2ms
104104
x-request-id:
105-
- req_421956d5955445a3ba608904b079e1ac
105+
- req_d086ae6f912847cda68256eccc846101
106106
status:
107107
code: 200
108108
message: OK

0 commit comments

Comments
 (0)