Skip to content

Commit ba19022

Browse files
committed
fix(responses): backfill missing status and created_at for strict decoders
1 parent 23a6348 commit ba19022

2 files changed

Lines changed: 182 additions & 5 deletions

File tree

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

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@ function nextSyntheticItemSlot(): ItemIdSlot {
9191
return { kind: "fallback", ordinal: syntheticItemOrdinal };
9292
}
9393

94+
/**
95+
* Backfill `status: "completed"` 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+
* Returns the same object reference if no change is needed.
104+
*/
105+
function backfillItemStatus(item: Record<string, unknown>): Record<string, unknown> {
106+
if (item.type !== "message") return item;
107+
if ("status" in item) return item;
108+
return { ...item, status: "completed" };
109+
}
110+
94111
/**
95112
* Backfill annotations: [] on an output_text content part if missing.
96113
* Returns the same object reference if no change is needed.
@@ -136,26 +153,39 @@ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
136153
const content = item.content;
137154
const repaired = backfillContentArray(content);
138155
const withId = backfillItemId(item, slot);
139-
if (repaired === content && withId === item) return item;
140-
return { ...withId, ...(repaired === content ? {} : { content: repaired }) };
156+
const withStatus = backfillItemStatus(withId);
157+
if (repaired === content && withStatus === item) return item;
158+
return { ...withStatus, ...(repaired === content ? {} : { content: repaired }) };
141159
}
142160

143161
/**
144162
* Walk a response object's output[] and backfill output_text parts.
163+
* Also backfills `created_at` on the response itself when absent.
145164
* Returns the same object reference if nothing changed.
146165
*/
147166
function backfillResponseOutput(response: unknown): unknown {
148167
if (!isPlainObject(response)) return response;
149-
const output = response.output;
150-
if (!Array.isArray(output)) return response;
168+
let current = response;
169+
170+
// Backfill created_at on the Response object. Strict Responses decoders (e.g. grok-build's
171+
// serde types) require `created_at: u64` — no `#[serde(default)]` — so an upstream relay that
172+
// omits it causes `missing field 'created_at'`. Use the current Unix epoch second; the exact
173+
// value is not semantically important to the client, but the field must be present.
174+
if (!("created_at" in current)) {
175+
current = { ...current, created_at: Math.floor(Date.now() / 1000) };
176+
}
177+
178+
const output = current.output;
179+
if (!Array.isArray(output)) return current === response ? response : current;
151180
let changed = false;
152181
const repaired = output.map((item, idx) => {
153182
if (!isPlainObject(item)) return item;
154183
const next = backfillOutputItem(item, { kind: "index", index: idx });
155184
if (next !== item) changed = true;
156185
return next;
157186
});
158-
return changed ? { ...response, output: repaired } : response;
187+
if (!changed && current === response) return response;
188+
return { ...current, output: repaired };
159189
}
160190

161191
/**

tests/responses-field-backfill.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,153 @@ describe("responses-field-backfill", () => {
350350
expect(result.output[0].id).toBe("msg_real");
351351
});
352352

353+
test("backfills missing status on output_item.done message", () => {
354+
const event = {
355+
type: "response.output_item.done",
356+
output_index: 0,
357+
item: {
358+
type: "message",
359+
id: "msg_1",
360+
role: "assistant",
361+
content: [{ type: "output_text", text: "hi" }],
362+
},
363+
};
364+
const [out] = apply(sseBlock(event));
365+
const parsed = parseData([out])[0];
366+
expect(parsed.item.status).toBe("completed");
367+
});
368+
369+
test("preserves existing status on message items", () => {
370+
const event = {
371+
type: "response.output_item.done",
372+
output_index: 0,
373+
item: {
374+
type: "message",
375+
id: "msg_1",
376+
role: "assistant",
377+
status: "in_progress",
378+
content: [{ type: "output_text", text: "hi" }],
379+
},
380+
};
381+
const [out] = apply(sseBlock(event));
382+
const parsed = parseData([out])[0];
383+
expect(parsed.item.status).toBe("in_progress");
384+
});
385+
386+
test("backfills missing status on response.completed output items", () => {
387+
const event = {
388+
type: "response.completed",
389+
sequence_number: 42,
390+
response: {
391+
id: "resp_1",
392+
object: "response",
393+
status: "completed",
394+
output: [
395+
{
396+
type: "message",
397+
id: "msg_1",
398+
role: "assistant",
399+
content: [{ type: "output_text", text: "hello" }],
400+
},
401+
],
402+
},
403+
};
404+
const [out] = apply(sseBlock(event));
405+
const parsed = parseData([out])[0];
406+
expect(parsed.response.output[0].status).toBe("completed");
407+
});
408+
409+
test("does not add status to non-message items", () => {
410+
const event = {
411+
type: "response.output_item.done",
412+
output_index: 0,
413+
item: {
414+
type: "function_call",
415+
id: "fc_1",
416+
call_id: "call_1",
417+
name: "do_thing",
418+
arguments: "{}",
419+
},
420+
};
421+
const [out] = apply(sseBlock(event));
422+
const parsed = parseData([out])[0];
423+
expect(parsed.item).not.toHaveProperty("status");
424+
});
425+
426+
test("backfillResponsesFieldsJson backfills missing status on message items", () => {
427+
const response = {
428+
id: "resp_1",
429+
object: "response",
430+
status: "completed",
431+
output: [
432+
{
433+
type: "message",
434+
id: "msg_1",
435+
role: "assistant",
436+
content: [{ type: "output_text", text: "hello" }],
437+
},
438+
],
439+
};
440+
const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as typeof response;
441+
expect(result.output[0].status).toBe("completed");
442+
});
443+
444+
test("backfills missing created_at on response.completed", () => {
445+
const event = {
446+
type: "response.completed",
447+
sequence_number: 42,
448+
response: {
449+
id: "resp_1",
450+
object: "response",
451+
status: "completed",
452+
model: "grok-4.5",
453+
output: [
454+
{
455+
type: "message",
456+
id: "msg_1",
457+
role: "assistant",
458+
status: "completed",
459+
content: [{ type: "output_text", text: "answer" }],
460+
},
461+
],
462+
},
463+
};
464+
const [out] = apply(sseBlock(event));
465+
const parsed = parseData([out])[0];
466+
expect(parsed.response.created_at).toEqual(expect.any(Number));
467+
expect(parsed.response.created_at).toBeGreaterThan(0);
468+
});
469+
470+
test("preserves existing created_at on response objects", () => {
471+
const event = {
472+
type: "response.created",
473+
sequence_number: 1,
474+
response: {
475+
id: "resp_1",
476+
object: "response",
477+
created_at: 1700000000,
478+
status: "in_progress",
479+
model: "grok-4.5",
480+
output: [],
481+
},
482+
};
483+
const [out] = apply(sseBlock(event));
484+
const parsed = parseData([out])[0];
485+
expect(parsed.response.created_at).toBe(1700000000);
486+
});
487+
488+
test("backfillResponsesFieldsJson backfills missing created_at", () => {
489+
const response = {
490+
id: "resp_1",
491+
object: "response",
492+
status: "completed",
493+
output: [],
494+
};
495+
const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as typeof response;
496+
expect(result.created_at).toEqual(expect.any(Number));
497+
expect(result.created_at).toBeGreaterThan(0);
498+
});
499+
353500
test("the canonical image_generation_call type gets its own prefix", () => {
354501
const response = {
355502
id: "resp_1",

0 commit comments

Comments
 (0)