@@ -91,6 +91,29 @@ function nextSyntheticItemSlot(): ItemIdSlot {
9191 return { kind : "fallback" , ordinal : syntheticItemOrdinal } ;
9292}
9393
94+ /**
95+ * Backfill `status` on a message output item if missing.
96+ *
97+ * The Responses API spec defines `status` as a required field on
98+ * `OutputMessage`. Some upstream relays omit it, which causes strict
99+ * deserializers (e.g. grok-build's serde types) to fail with
100+ * `missing field 'status'`. Only message items carry this field in the
101+ * Responses schema; reasoning, function_call, and other item types do not.
102+ *
103+ * The value is inferred from the event context: `output_item.added` and
104+ * `response.created` / `response.in_progress` mean the message is still
105+ * being generated (`in_progress`); `output_item.done` and
106+ * `response.completed` / `response.incomplete` mean the message is
107+ * finalized (`completed` / `incomplete` respectively).
108+ *
109+ * Returns the same object reference if no change is needed.
110+ */
111+ function backfillItemStatus ( item : Record < string , unknown > , inferredStatus : string ) : Record < string , unknown > {
112+ if ( item . type !== "message" ) return item ;
113+ if ( "status" in item ) return item ;
114+ return { ...item , status : inferredStatus } ;
115+ }
116+
94117/**
95118 * Backfill annotations: [] on an output_text content part if missing.
96119 * Returns the same object reference if no change is needed.
@@ -122,10 +145,10 @@ function backfillContentArray(content: unknown): unknown {
122145
123146/**
124147 * Walk an output item and backfill output_text parts in its content.
125- * Also backfills a missing required id on the item itself.
148+ * Also backfills a missing required id and status on the item itself.
126149 * Returns the same object reference if nothing changed.
127150 */
128- function backfillOutputItem ( item : unknown , slot : ItemIdSlot ) : unknown {
151+ function backfillOutputItem ( item : unknown , slot : ItemIdSlot , inferredStatus : string ) : unknown {
129152 if ( ! isPlainObject ( item ) ) return item ;
130153 // The compact wire family is the `/v1/responses/compact` format, not a Responses output item.
131154 // Those items have no `id` in that contract, so synthesizing one changes a response body the
@@ -136,34 +159,76 @@ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
136159 const content = item . content ;
137160 const repaired = backfillContentArray ( content ) ;
138161 const withId = backfillItemId ( item , slot ) ;
139- if ( repaired === content && withId === item ) return item ;
140- return { ...withId , ...( repaired === content ? { } : { content : repaired } ) } ;
162+ const withStatus = backfillItemStatus ( withId , inferredStatus ) ;
163+ if ( repaired === content && withStatus === item ) return item ;
164+ return { ...withStatus , ...( repaired === content ? { } : { content : repaired } ) } ;
141165}
142166
143167/**
144168 * Walk a response object's output[] and backfill output_text parts.
169+ * Also backfills `created_at` on the response itself when absent.
170+ *
171+ * `createdAt` is captured once per rewrite factory (SSE) or per call (JSON)
172+ * so every event in the same stream carries the same timestamp, even if the
173+ * stream spans a second boundary.
174+ *
175+ * `inferredItemStatus` is the status to backfill on message items that lack
176+ * one — derived from the event type so `output_item.added` / `response.created`
177+ * gets `in_progress` while `output_item.done` / `response.completed` gets
178+ * `completed`.
179+ *
145180 * Returns the same object reference if nothing changed.
146181 */
147- function backfillResponseOutput ( response : unknown ) : unknown {
182+ function backfillResponseOutput ( response : unknown , createdAt : number , inferredItemStatus : string ) : unknown {
148183 if ( ! isPlainObject ( response ) ) return response ;
149- const output = response . output ;
150- if ( ! Array . isArray ( output ) ) return response ;
184+ let current = response ;
185+
186+ // Backfill created_at on the Response object. Strict Responses decoders (e.g. grok-build's
187+ // serde types) require `created_at: u64` — no `#[serde(default)]` — so an upstream relay that
188+ // omits it causes `missing field 'created_at'`. Use a timestamp captured once per rewrite
189+ // factory so every event in the same stream agrees, even if the stream spans a second
190+ // boundary.
191+ if ( ! ( "created_at" in current ) ) {
192+ current = { ...current , created_at : createdAt } ;
193+ }
194+
195+ const output = current . output ;
196+ if ( ! Array . isArray ( output ) ) return current === response ? response : current ;
151197 let changed = false ;
152198 const repaired = output . map ( ( item , idx ) => {
153199 if ( ! isPlainObject ( item ) ) return item ;
154- const next = backfillOutputItem ( item , { kind : "index" , index : idx } ) ;
200+ const next = backfillOutputItem ( item , { kind : "index" , index : idx } , inferredItemStatus ) ;
155201 if ( next !== item ) changed = true ;
156202 return next ;
157203 } ) ;
158- return changed ? { ...response , output : repaired } : response ;
204+ if ( ! changed && current === response ) return response ;
205+ return { ...current , output : repaired } ;
206+ }
207+
208+ /**
209+ * Infer the status to backfill on a message item from the event type.
210+ *
211+ * `output_item.added` means the item is still being generated (`in_progress`);
212+ * `output_item.done` means it is finalized (`completed`). Response-level events
213+ * infer from the response's own status: `in_progress` for `response.created` /
214+ * `response.in_progress`, `completed` for `response.completed`, `incomplete`
215+ * for `response.incomplete`, and `completed` as the safe default for
216+ * `response.failed` (the message is finalized, just not successfully).
217+ */
218+ function inferredStatusForEventType ( eventType : string ) : string {
219+ if ( eventType === "response.output_item.added" ) return "in_progress" ;
220+ if ( eventType === "response.output_item.done" ) return "completed" ;
221+ // Response-level events: infer from the response status field.
222+ return "completed" ;
159223}
160224
161225/**
162226 * Statelessly rewrite one SSE event: backfill annotations
163227 * on any output_text content part found in the event payload.
164228 */
165- function rewriteEvent ( event : Record < string , unknown > ) : Record < string , unknown > {
229+ function rewriteEvent ( event : Record < string , unknown > , createdAt : number ) : Record < string , unknown > {
166230 const type = typeof event . type === "string" ? event . type : "" ;
231+ const inferredItemStatus = inferredStatusForEventType ( type ) ;
167232 let next = event ;
168233 let changed = false ;
169234
@@ -177,8 +242,8 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
177242 // is not recoverable in that case, but a unique id is what strict decoders require, and a
178243 // well-formed stream still gets the stable index-derived id.
179244 const item = typeof rawIndex === "number" && Number . isInteger ( rawIndex ) && rawIndex >= 0
180- ? backfillOutputItem ( event . item , { kind : "index" , index : rawIndex } )
181- : backfillOutputItem ( event . item , nextSyntheticItemSlot ( ) ) ;
245+ ? backfillOutputItem ( event . item , { kind : "index" , index : rawIndex } , inferredItemStatus )
246+ : backfillOutputItem ( event . item , nextSyntheticItemSlot ( ) , inferredItemStatus ) ;
182247 if ( item !== event . item ) {
183248 next = { ...next , item } ;
184249 changed = true ;
@@ -198,7 +263,10 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
198263 // response.created / in_progress / completed / incomplete / failed:
199264 // response.output[].content[] -> output_text parts
200265 if ( isPlainObject ( event . response ) ) {
201- const response = backfillResponseOutput ( event . response ) ;
266+ // For response-level events, infer the item status from the response's own status
267+ // field when present, falling back to the event-type inference.
268+ const responseStatus = typeof event . response . status === "string" ? event . response . status : inferredItemStatus ;
269+ const response = backfillResponseOutput ( event . response , createdAt , responseStatus ) ;
202270 if ( response !== event . response ) {
203271 next = { ...next , response } ;
204272 changed = true ;
@@ -213,8 +281,13 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
213281 * on output_text content parts. Unconditional: the field is a required
214282 * canonical Responses field, so adding it when absent is safe for all
215283 * clients.
284+ *
285+ * `created_at` is captured once at factory creation time so every event in
286+ * the same stream carries the same timestamp, even if the stream spans a
287+ * second boundary.
216288 */
217289export function createResponsesFieldBackfillBlockRewrite ( ) : SseBlockRewrite {
290+ const createdAt = Math . floor ( Date . now ( ) / 1000 ) ;
218291 const rewrite : SseBlockRewrite = ( block : string ) : readonly string [ ] => {
219292 const payload = sseDataPayload ( block ) ;
220293 if ( payload === null ) return [ block ] ;
@@ -225,7 +298,7 @@ export function createResponsesFieldBackfillBlockRewrite(): SseBlockRewrite {
225298 return [ block ] ;
226299 }
227300 if ( ! isPlainObject ( event ) ) return [ block ] ;
228- const rewritten = rewriteEvent ( event ) ;
301+ const rewritten = rewriteEvent ( event , createdAt ) ;
229302 if ( rewritten === event ) return [ block ] ;
230303 return [ replaceSseDataPayload ( block , JSON . stringify ( rewritten ) ) ] ;
231304 } ;
@@ -245,7 +318,9 @@ export function backfillResponsesFieldsJson(payload: string): string {
245318 return payload ;
246319 }
247320 if ( ! isPlainObject ( response ) ) return payload ;
248- const repaired = backfillResponseOutput ( response ) ;
321+ const createdAt = Math . floor ( Date . now ( ) / 1000 ) ;
322+ // For a non-streaming response, the response is already terminal, so items get "completed".
323+ const repaired = backfillResponseOutput ( response , createdAt , "completed" ) ;
249324 if ( repaired === response ) return payload ;
250325 return JSON . stringify ( repaired ) ;
251326}
0 commit comments