@@ -57,15 +57,44 @@ type ResponsesInputItem =
5757 | { type : "reasoning" ; summary : never [ ] ; encrypted_content : string } ;
5858
5959// 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 ;
60+ // issued for a specific model; only that backend can decrypt it. `model` is
61+ // arbitrary catalog/user-supplied text — nothing stops two distinct backends
62+ // (proxy aliases, two OpenAI-compatible endpoints) from declaring the same
63+ // literal model name, so comparing `turn.model` alone treats a foreign
64+ // signature as safe to replay. `ConversationTurn` carries no field for which
65+ // provider produced it, so provenance rides inside the signature string
66+ // itself: capture tags it `<provider>:<ciphertext>` (see `tagSignature`),
67+ // and replay only unwraps the ciphertext when both the tagged provider and
68+ // the model match the current request.
69+ //
70+ // Provider, not the per-account source id, is the unit of decrypt
71+ // capability — a Codex backend shared across ChatGPT accounts can decrypt a
72+ // signature issued to any of them, so keying on provider (rather than source
73+ // id) is what lets an account switch keep reasoning continuity while a
74+ // genuine cross-provider collision still gets dropped. A poisoned history
75+ // self-heals on the next request instead of being replayed forever.
76+ const SIGNATURE_TAG_SEPARATOR = ":" ;
77+
78+ export function tagSignature ( provider : string , encryptedContent : string ) : string {
79+ return `${ provider } ${ SIGNATURE_TAG_SEPARATOR } ${ encryptedContent } ` ;
80+ }
81+
82+ function untagSignature ( tagged : string ) : { provider : string ; encryptedContent : string } | undefined {
83+ const idx = tagged . indexOf ( SIGNATURE_TAG_SEPARATOR ) ;
84+ if ( idx === - 1 ) return undefined ;
85+ return { provider : tagged . slice ( 0 , idx ) , encryptedContent : tagged . slice ( idx + 1 ) } ;
86+ }
87+
88+ export function signatureForModel (
89+ turn : ConversationTurn ,
90+ requestModel : string ,
91+ requestProvider : string ,
92+ signature : string ,
93+ ) : string | undefined {
94+ if ( turn . model !== requestModel ) return undefined ;
95+ const tagged = untagSignature ( signature ) ;
96+ if ( tagged === undefined ) return undefined ;
97+ return tagged . provider === requestProvider ? tagged . encryptedContent : undefined ;
6998}
7099
71100// Map one internal turn to zero or more Responses items. Assistant text uses
@@ -76,7 +105,7 @@ export function signatureForModel(turn: ConversationTurn, requestModel: string,
76105// (held in a thinking block's signature) AND that backend is the one this
77106// request is going to — replaying it to a different provider gets a 400 it
78107// cannot recover from.
79- function toResponsesItems ( turn : ConversationTurn , requestModel : string ) : ResponsesInputItem [ ] {
108+ function toResponsesItems ( turn : ConversationTurn , requestModel : string , requestProvider : string ) : ResponsesInputItem [ ] {
80109 const items : ResponsesInputItem [ ] = [ ] ;
81110 const textKind : "input_text" | "output_text" = turn . role === "assistant" ? "output_text" : "input_text" ;
82111 const textParts : ResponsesContentPart [ ] = [ ] ;
@@ -112,7 +141,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons
112141 items . push ( { type : "function_call_output" , call_id : block . callId , output : toolResultText ( block ) } ) ;
113142 } else if ( block . type === "thinking" && typeof block . signature === "string" && block . signature . length > 0 ) {
114143 flushText ( ) ;
115- const encryptedContent = signatureForModel ( turn , requestModel , block . signature ) ;
144+ const encryptedContent = signatureForModel ( turn , requestModel , requestProvider , block . signature ) ;
116145 if ( encryptedContent !== undefined ) {
117146 items . push ( { type : "reasoning" , summary : [ ] , encrypted_content : encryptedContent } ) ;
118147 }
@@ -169,8 +198,9 @@ function buildRequest(
169198 messages : ConversationTurn [ ] ,
170199 model : string ,
171200 options : InferenceOptions ,
201+ requestProvider : string ,
172202) : BuiltRequest {
173- const conversation = messages . flatMap ( ( turn ) => toResponsesItems ( turn , model ) ) ;
203+ const conversation = messages . flatMap ( ( turn ) => toResponsesItems ( turn , model , requestProvider ) ) ;
174204 // Corbits Code's prompt cannot live in `instructions` (the backend pins that to
175205 // the official Codex prompt), so it leads the input as a developer message.
176206 const input =
@@ -377,7 +407,7 @@ export function parseResponse(
377407 events . push ( {
378408 type : "inference.thinking.signature" ,
379409 seq,
380- data : { signature : item [ "encrypted_content" ] , index } ,
410+ data : { signature : tagSignature ( source . provider , item [ "encrypted_content" ] as string ) , index } ,
381411 } ) ;
382412 }
383413 return events ;
@@ -457,7 +487,7 @@ export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAd
457487 items : new Map < string , { index : number ; kind : CodexBlockKind } > ( ) ,
458488 } ;
459489 return {
460- buildRequest,
490+ buildRequest : ( messages , model , options ) => buildRequest ( messages , model , options , source . provider ) ,
461491 parseResponse : ( sseData ) => parseResponse ( sseData , indexer , source ) ,
462492 isStreamTerminal : isResponsesStreamTerminal ,
463493 } ;
0 commit comments