@@ -6,6 +6,11 @@ type OpenAIThinkingBlock = {
66 thinkingSignature ?: unknown ;
77} ;
88
9+ type OpenAIToolCallBlock = {
10+ type ?: unknown ;
11+ id ?: unknown ;
12+ } ;
13+
914type OpenAIReasoningSignature = {
1015 id : string ;
1116 type : string ;
@@ -59,6 +64,141 @@ function hasFollowingNonThinkingBlock(
5964 return false ;
6065}
6166
67+ function splitOpenAIFunctionCallPairing ( id : string ) : {
68+ callId : string ;
69+ itemId ?: string ;
70+ } {
71+ const separator = id . indexOf ( "|" ) ;
72+ if ( separator <= 0 || separator >= id . length - 1 ) {
73+ return { callId : id } ;
74+ }
75+ return {
76+ callId : id . slice ( 0 , separator ) ,
77+ itemId : id . slice ( separator + 1 ) ,
78+ } ;
79+ }
80+
81+ function isOpenAIToolCallType ( type : unknown ) : boolean {
82+ return type === "toolCall" || type === "toolUse" || type === "functionCall" ;
83+ }
84+
85+ /**
86+ * OpenAI can reject replayed `function_call` items with an `fc_*` id if the
87+ * matching `reasoning` item is absent in the same assistant turn.
88+ *
89+ * When that pairing is missing, strip the `|fc_*` suffix from tool call ids so
90+ * pi-ai omits `function_call.id` on replay.
91+ */
92+ export function downgradeOpenAIFunctionCallReasoningPairs (
93+ messages : AgentMessage [ ] ,
94+ ) : AgentMessage [ ] {
95+ let changed = false ;
96+ const rewrittenMessages : AgentMessage [ ] = [ ] ;
97+ let pendingRewrittenIds : Map < string , string > | null = null ;
98+
99+ for ( const msg of messages ) {
100+ if ( ! msg || typeof msg !== "object" ) {
101+ pendingRewrittenIds = null ;
102+ rewrittenMessages . push ( msg ) ;
103+ continue ;
104+ }
105+
106+ const role = ( msg as { role ?: unknown } ) . role ;
107+ if ( role === "assistant" ) {
108+ const assistantMsg = msg as Extract < AgentMessage , { role : "assistant" } > ;
109+ if ( ! Array . isArray ( assistantMsg . content ) ) {
110+ pendingRewrittenIds = null ;
111+ rewrittenMessages . push ( msg ) ;
112+ continue ;
113+ }
114+
115+ const localRewrittenIds = new Map < string , string > ( ) ;
116+ let seenReplayableReasoning = false ;
117+ let assistantChanged = false ;
118+ const nextContent = assistantMsg . content . map ( ( block ) => {
119+ if ( ! block || typeof block !== "object" ) {
120+ return block ;
121+ }
122+
123+ const thinkingBlock = block as OpenAIThinkingBlock ;
124+ if (
125+ thinkingBlock . type === "thinking" &&
126+ parseOpenAIReasoningSignature ( thinkingBlock . thinkingSignature )
127+ ) {
128+ seenReplayableReasoning = true ;
129+ return block ;
130+ }
131+
132+ const toolCallBlock = block as OpenAIToolCallBlock ;
133+ if ( ! isOpenAIToolCallType ( toolCallBlock . type ) || typeof toolCallBlock . id !== "string" ) {
134+ return block ;
135+ }
136+
137+ const pairing = splitOpenAIFunctionCallPairing ( toolCallBlock . id ) ;
138+ if ( seenReplayableReasoning || ! pairing . itemId || ! pairing . itemId . startsWith ( "fc_" ) ) {
139+ return block ;
140+ }
141+
142+ assistantChanged = true ;
143+ localRewrittenIds . set ( toolCallBlock . id , pairing . callId ) ;
144+ return {
145+ ...( block as unknown as Record < string , unknown > ) ,
146+ id : pairing . callId ,
147+ } as typeof block ;
148+ } ) ;
149+
150+ pendingRewrittenIds = localRewrittenIds . size > 0 ? localRewrittenIds : null ;
151+ if ( ! assistantChanged ) {
152+ rewrittenMessages . push ( msg ) ;
153+ continue ;
154+ }
155+ changed = true ;
156+ rewrittenMessages . push ( { ...assistantMsg , content : nextContent } as AgentMessage ) ;
157+ continue ;
158+ }
159+
160+ if ( role === "toolResult" && pendingRewrittenIds && pendingRewrittenIds . size > 0 ) {
161+ const toolResult = msg as Extract < AgentMessage , { role : "toolResult" } > & {
162+ toolUseId ?: unknown ;
163+ } ;
164+ let toolResultChanged = false ;
165+ const updates : Record < string , string > = { } ;
166+
167+ if ( typeof toolResult . toolCallId === "string" ) {
168+ const nextToolCallId = pendingRewrittenIds . get ( toolResult . toolCallId ) ;
169+ if ( nextToolCallId && nextToolCallId !== toolResult . toolCallId ) {
170+ updates . toolCallId = nextToolCallId ;
171+ toolResultChanged = true ;
172+ }
173+ }
174+
175+ if ( typeof toolResult . toolUseId === "string" ) {
176+ const nextToolUseId = pendingRewrittenIds . get ( toolResult . toolUseId ) ;
177+ if ( nextToolUseId && nextToolUseId !== toolResult . toolUseId ) {
178+ updates . toolUseId = nextToolUseId ;
179+ toolResultChanged = true ;
180+ }
181+ }
182+
183+ if ( ! toolResultChanged ) {
184+ rewrittenMessages . push ( msg ) ;
185+ continue ;
186+ }
187+ changed = true ;
188+ rewrittenMessages . push ( {
189+ ...toolResult ,
190+ ...updates ,
191+ } as AgentMessage ) ;
192+ continue ;
193+ }
194+
195+ pendingRewrittenIds = null ;
196+ rewrittenMessages . push ( msg ) ;
197+ }
198+
199+ return changed ? rewrittenMessages : messages ;
200+ }
201+
62202/**
63203 * OpenAI Responses API can reject transcripts that contain a standalone `reasoning` item id
64204 * without the required following item.
0 commit comments