Skip to content

Commit 86b95b6

Browse files
committed
fix(responses): backfill missing status and created_at for strict decoders
Follow-up to #2142. Two more required fields cause the same strict-decoder crash on the Responses passthrough path when an upstream relay omits them: OutputMessage.status (no #[serde(default)] in the async-openai fork) and Response.created_at (u64, no default). Status is inferred from event context: output_item.added gets in_progress, output_item.done gets completed, and response-level events derive it from the response status field. created_at is captured once per rewrite factory so every event in the same stream agrees, even across a second boundary. Both backfills are wired through backfillOutputItem / backfillResponseOutput, covering SSE and bounded-JSON passthrough. Existing values are never overwritten. The translation path (bridge.ts) already emits both fields.
1 parent 23a6348 commit 86b95b6

2 files changed

Lines changed: 342 additions & 15 deletions

File tree

src/server/responses/responses-field-backfill.ts

Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
217289
export 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

Comments
 (0)