@@ -23,6 +23,15 @@ export type PromptArgs = {
2323 [ key : string ] : unknown ;
2424} ;
2525
26+ /**
27+ * Keep a prompt body independent from SDK/client mutation. Some prompt facades
28+ * normalize or consume request bodies while handling a failed attempt; fallback
29+ * attempts must start from the original system prompt and request body every time.
30+ */
31+ function copyPromptArgs ( args : PromptArgs , body : PromptBody ) : PromptArgs {
32+ return { ...args , body : { ...body } } ;
33+ }
34+
2635export interface PromptAttemptInfo {
2736 /** Human-readable model label used in logs ("primary" or "provider/model"). */
2837 label : string ;
@@ -280,16 +289,21 @@ async function attemptOnce(
280289 callContext : string ,
281290 label : string ,
282291) : Promise < void > {
292+ // Keep this snapshot separate from the object passed to the client. A
293+ // failed prompt facade may rewrite its request body before rejecting; the
294+ // model-suggestion retry still needs the original system prompt.
295+ const originalBody = { ...args . body } ;
296+ const attemptArgs = copyPromptArgs ( args , originalBody ) ;
283297 try {
284- await promptWithTimeout ( client , args , timeoutMs , signal ) ;
298+ await promptWithTimeout ( client , attemptArgs , timeoutMs , signal ) ;
285299 return ;
286300 } catch ( error ) {
287301 // If non-retryable (abort, overflow, timeout), bubble up immediately.
288302 // Don't even try suggestion retry — caller needs the original error.
289303 if ( isNonRetryable ( error , signal ) ) throw error ;
290304
291305 const suggestion = parseModelSuggestion ( error ) ;
292- if ( ! suggestion || ! args . body . model ) {
306+ if ( ! suggestion || ! originalBody . model ) {
293307 // No suggestion available — caller's fallback loop will decide
294308 // whether to try the next chain entry.
295309 throw error ;
@@ -302,16 +316,13 @@ async function attemptOnce(
302316
303317 await promptWithTimeout (
304318 client ,
305- {
306- ...args ,
307- body : {
308- ...args . body ,
309- model : {
310- providerID : suggestion . providerID ,
311- modelID : suggestion . suggestion ,
312- } ,
319+ copyPromptArgs ( args , {
320+ ...originalBody ,
321+ model : {
322+ providerID : suggestion . providerID ,
323+ modelID : suggestion . suggestion ,
313324 } ,
314- } ,
325+ } ) ,
315326 timeoutMs ,
316327 signal ,
317328 ) ;
@@ -340,20 +351,25 @@ export async function promptSyncWithModelSuggestionRetry(
340351 const timeoutMs = options . timeoutMs ?? 300_000 ;
341352 const callContext = options . callContext ?? "subagent" ;
342353 const fallbacks = options . fallbackModels ?? [ ] ;
354+ // Snapshot the body before the first client call. The Pi facade and the
355+ // OpenCode SDK both receive this same shape, and either may mutate it while
356+ // handling a failed request. Fallbacks must never inherit that mutation.
357+ const baseBody = { ...args . body } ;
358+ const baseArgs = copyPromptArgs ( args , baseBody ) ;
343359
344360 // Attempt 0 = whatever the agent or explicit body.model resolves to.
345361 // Subsequent attempts override body.model with each fallback in order.
346362 const explicitPrimaryLabel =
347- args . body . model ?. providerID && args . body . model . modelID
348- ? `${ args . body . model . providerID } /${ args . body . model . modelID } `
363+ baseBody . model ?. providerID && baseBody . model . modelID
364+ ? `${ baseBody . model . providerID } /${ baseBody . model . modelID } `
349365 : "primary" ;
350366
351367 let lastError : unknown = null ;
352368
353369 try {
354370 await attemptOnce (
355371 client ,
356- args ,
372+ baseArgs ,
357373 timeoutMs ,
358374 options . signal ,
359375 callContext ,
@@ -385,10 +401,10 @@ export async function promptSyncWithModelSuggestionRetry(
385401 }
386402
387403 const label = `${ parsed . providerID } /${ parsed . modelID } ` ;
388- const attemptArgs : PromptArgs = {
389- ...args ,
390- body : { ... args . body , model : parsed } ,
391- } ;
404+ const attemptArgs = copyPromptArgs ( baseArgs , {
405+ ...baseBody ,
406+ model : parsed ,
407+ } ) ;
392408
393409 try {
394410 await attemptOnce ( client , attemptArgs , timeoutMs , options . signal , callContext , label ) ;
@@ -453,10 +469,15 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
453469 const timeoutMs = options . timeoutMs ?? 300_000 ;
454470 const callContext = options . callContext ?? "subagent" ;
455471 const fallbacks = options . fallbackModels ?? [ ] ;
472+ // Snapshot the body before the first client call. The Pi facade and the
473+ // OpenCode SDK both receive this same shape, and either may mutate it while
474+ // handling a failed request. Fallbacks must never inherit that mutation.
475+ const baseBody = { ...args . body } ;
476+ const baseArgs = copyPromptArgs ( args , baseBody ) ;
456477
457478 const explicitPrimaryLabel =
458- args . body . model ?. providerID && args . body . model . modelID
459- ? `${ args . body . model . providerID } /${ args . body . model . modelID } `
479+ baseBody . model ?. providerID && baseBody . model . modelID
480+ ? `${ baseBody . model . providerID } /${ baseBody . model . modelID } `
460481 : "primary" ;
461482 const totalAttempts = fallbacks . length + 1 ;
462483
@@ -466,7 +487,7 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
466487 try {
467488 return await attemptAndValidate (
468489 client ,
469- args ,
490+ baseArgs ,
470491 timeoutMs ,
471492 options . signal ,
472493 callContext ,
@@ -475,7 +496,7 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
475496 attemptIndex : 0 ,
476497 isFallback : false ,
477498 totalAttempts,
478- model : args . body . model ,
499+ model : baseBody . model ,
479500 } ,
480501 options ,
481502 ) ;
@@ -501,10 +522,10 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
501522 }
502523
503524 const label = `${ parsed . providerID } /${ parsed . modelID } ` ;
504- const attemptArgs : PromptArgs = {
505- ...args ,
506- body : { ... args . body , model : parsed } ,
507- } ;
525+ const attemptArgs = copyPromptArgs ( baseArgs , {
526+ ...baseBody ,
527+ model : parsed ,
528+ } ) ;
508529 const attempt : PromptAttemptInfo = {
509530 label,
510531 attemptIndex : i + 1 ,
0 commit comments