@@ -56,14 +56,27 @@ type ResponsesInputItem =
5656 | { type : "function_call_output" ; call_id : string ; output : string }
5757 | { type : "reasoning" ; summary : never [ ] ; encrypted_content : string } ;
5858
59+ // A thinking block's `signature` is opaque ciphertext a specific backend
60+ // issued for a specific model; only that backend can decrypt it. `turn.model`
61+ // records which model produced the turn, so comparing it against the model
62+ // this request is being built for is enough provenance to tell whether a
63+ // signature is safe to replay — no separate provenance field is needed.
64+ // Switching models means turns from the old model simply stop qualifying, so
65+ // a poisoned history self-heals on the very next request instead of being
66+ // replayed forever.
67+ export function signatureForModel ( turn : ConversationTurn , requestModel : string , signature : string ) : string | undefined {
68+ return turn . model === requestModel ? signature : undefined ;
69+ }
70+
5971// Map one internal turn to zero or more Responses items. Assistant text uses
6072// `output_text` parts; user/system text uses `input_text`. Tool calls become
6173// `function_call` items (arguments serialized to a JSON string) and tool
6274// results become `function_call_output` items. Reasoning blocks are echoed
6375// back only when they carry the opaque `encrypted_content` the backend issued
64- // (held in a thinking block's signature), which is required for multi-turn
65- // reasoning continuity.
66- function toResponsesItems ( turn : ConversationTurn ) : ResponsesInputItem [ ] {
76+ // (held in a thinking block's signature) AND that backend is the one this
77+ // request is going to — replaying it to a different provider gets a 400 it
78+ // cannot recover from.
79+ function toResponsesItems ( turn : ConversationTurn , requestModel : string ) : ResponsesInputItem [ ] {
6780 const items : ResponsesInputItem [ ] = [ ] ;
6881 const textKind : "input_text" | "output_text" = turn . role === "assistant" ? "output_text" : "input_text" ;
6982 const textParts : ResponsesContentPart [ ] = [ ] ;
@@ -99,7 +112,10 @@ function toResponsesItems(turn: ConversationTurn): ResponsesInputItem[] {
99112 items . push ( { type : "function_call_output" , call_id : block . callId , output : toolResultText ( block ) } ) ;
100113 } else if ( block . type === "thinking" && typeof block . signature === "string" && block . signature . length > 0 ) {
101114 flushText ( ) ;
102- items . push ( { type : "reasoning" , summary : [ ] , encrypted_content : block . signature } ) ;
115+ const encryptedContent = signatureForModel ( turn , requestModel , block . signature ) ;
116+ if ( encryptedContent !== undefined ) {
117+ items . push ( { type : "reasoning" , summary : [ ] , encrypted_content : encryptedContent } ) ;
118+ }
103119 }
104120 }
105121 flushText ( ) ;
@@ -154,7 +170,7 @@ function buildRequest(
154170 model : string ,
155171 options : InferenceOptions ,
156172) : BuiltRequest {
157- const conversation = messages . flatMap ( toResponsesItems ) ;
173+ const conversation = messages . flatMap ( ( turn ) => toResponsesItems ( turn , model ) ) ;
158174 // Corbits Code's prompt cannot live in `instructions` (the backend pins that to
159175 // the official Codex prompt), so it leads the input as a developer message.
160176 const input =
0 commit comments