diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index 08a1073805..f1192b73e2 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -3,10 +3,10 @@ title: Grok Build description: Use any opencodex-routed model from xAI's Grok Build CLI — models are auto-registered into ~/.grok/config.toml while the proxy runs. --- -opencodex serves an OpenAI-compatible `POST /v1/chat/completions` (and `/v1/responses`) on its -local port, and Grok Build supports custom models against OpenAI-compatible servers. Starting -with this integration, opencodex registers its whole visible catalog into Grok Build -automatically — no manual config editing required. +opencodex serves an OpenAI-compatible `POST /v1/responses` on its local port, and Grok Build +supports custom models against OpenAI-compatible servers. Starting with this integration, +opencodex registers its whole visible catalog into Grok Build automatically — no manual config +editing required. ## Auto-registration @@ -18,7 +18,7 @@ into `~/.grok/config.toml`: [model.ocx-gpt-5-6-sol] model = "gpt-5.6-sol" base_url = "http://127.0.0.1:10100/v1" -api_backend = "chat_completions" +api_backend = "responses" api_key = "opencodex-loopback" name = "OCX gpt-5.6-sol" # ... one [model.ocx-*] table per visible model ... @@ -61,12 +61,11 @@ in Codex. Models with an empty tier list keep no effort control, matching Codex behavior. Native GPT-5.6 entries are separate: they preserve and expose their pinned upstream reasoning ladders rather than provider-configured routed metadata. -Grok Build talks to opencodex over Chat Completions and sends `reasoning_effort` when -the ladder is advertised. The Chat Completions inbound translator defaults the internal -Responses `reasoning.summary` to `auto` in that case, so thinking traces reach Grok as -`delta.reasoning_content` instead of being hidden. Set `include_reasoning: false` (or -`reasoning.summary: "none"`) if a client wants the model to think without returning the -trace. An explicit `reasoning.summary` wins when both knobs are present. +Grok Build talks to opencodex over the Responses API. When the route advertises a reasoning +ladder, the Responses passthrough forwards `reasoning.summary` as configured, so thinking +traces reach Grok natively as Responses reasoning items. Set `reasoning.summary: "none"` if +a client wants the model to think without returning the trace. An explicit `reasoning.summary` +wins over the route default. ## Authentication note diff --git a/src/server/responses/responses-field-backfill.ts b/src/server/responses/responses-field-backfill.ts index dff2da1158..4d67531020 100644 --- a/src/server/responses/responses-field-backfill.ts +++ b/src/server/responses/responses-field-backfill.ts @@ -29,6 +29,59 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +/** Wire prefixes for Responses output item ids, matching OpenAI's id shapes. */ +const ITEM_ID_PREFIXES: Readonly> = { + message: "msg_", + reasoning: "rs_", + function_call: "fc_", + web_search_call: "ws_", + file_search_call: "fs_", + code_interpreter_call: "ci_", + computer_call: "cc_", + // The Responses wire type is `image_generation_call`; `image_gen_call` is kept only so a + // relay that emits the short spelling is not silently demoted to the generic `item_`. + image_generation_call: "ig_", + image_gen_call: "ig_", +}; + +/** + * Backfill a required id on an output item when absent. Strict Responses + * decoders (e.g. grok-build serde types) fail with "missing field id" when a + * message or reasoning item has no id, which some upstream relays omit. The + * generated id is deterministic per (type, output index) so it stays stable + * across streaming events that reference the same item. + */ +function backfillItemId(item: Record, slot: ItemIdSlot): Record { + if (typeof item.id === "string" && item.id.length > 0) return item; + const type = typeof item.type === "string" ? item.type : ""; + const prefix = Object.prototype.hasOwnProperty.call(ITEM_ID_PREFIXES, type) ? ITEM_ID_PREFIXES[type] : "item_"; + return { ...item, id: prefix + "ocx_" + (slot.kind === "index" ? String(slot.index) : "fallback_" + slot.ordinal) }; +} + +/** + * Which namespace a synthesized id comes from. + * + * Keeping the fallback counter in the SAME numeric namespace as real output indexes only + * pushed the collision out of reach rather than removing it: a response whose real index + * happened to be 1_000_001 would produce the same id as the first malformed-index fallback, + * and a duplicate id is exactly what this backfill exists to prevent. The namespaces are now + * lexically disjoint, so no index value can ever collide with a fallback. + */ +type ItemIdSlot = { kind: "index"; index: number } | { kind: "fallback"; ordinal: number }; + +/** + * Monotonic ordinal for an event whose `output_index` is absent or malformed. + * + * Process-global rather than per-response because this module is stateless by design and the + * value only has to be unique, not meaningful. It carries its own `fallback_` namespace, so + * uniqueness no longer depends on a real index never reaching some arbitrary ceiling. + */ +let syntheticItemOrdinal = 0; +function nextSyntheticItemSlot(): ItemIdSlot { + syntheticItemOrdinal += 1; + return { kind: "fallback", ordinal: syntheticItemOrdinal }; +} + /** * Backfill annotations: [] on an output_text content part if missing. * Returns the same object reference if no change is needed. @@ -58,16 +111,29 @@ function backfillContentArray(content: unknown): unknown { return changed ? repaired : content; } +/** + * Item types that are NOT Responses output items and must be returned byte-for-byte. + * + * `compaction` is the `/v1/responses/compact` wire format, not a Responses output item. It has + * no `id` in that contract, so synthesizing one changes a response body the client compares + * exactly. The backfill exists to satisfy strict Responses decoders; a shape those decoders + * never see is outside its remit. + */ +const NON_RESPONSES_ITEM_TYPES: ReadonlySet = new Set(["compaction"]); + /** * Walk an output item and backfill output_text parts in its content. + * Also backfills a missing required id on the item itself. * Returns the same object reference if nothing changed. */ -function backfillOutputItem(item: unknown): unknown { +function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown { if (!isPlainObject(item)) return item; + if (typeof item.type === "string" && NON_RESPONSES_ITEM_TYPES.has(item.type)) return item; const content = item.content; const repaired = backfillContentArray(content); - if (repaired === content) return item; - return { ...item, content: repaired }; + const withId = backfillItemId(item, slot); + if (repaired === content && withId === item) return item; + return { ...withId, ...(repaired === content ? {} : { content: repaired }) }; } /** @@ -79,9 +145,9 @@ function backfillResponseOutput(response: unknown): unknown { const output = response.output; if (!Array.isArray(output)) return response; let changed = false; - const repaired = output.map((item) => { + const repaired = output.map((item, idx) => { if (!isPlainObject(item)) return item; - const next = backfillOutputItem(item); + const next = backfillOutputItem(item, { kind: "index", index: idx }); if (next !== item) changed = true; return next; }); @@ -100,7 +166,15 @@ function rewriteEvent(event: Record): Record { // output_item.added / output_item.done: item.content[] -> output_text parts if ((type === "response.output_item.added" || type === "response.output_item.done") && isPlainObject(event.item)) { - const item = backfillOutputItem(event.item); + const rawIndex = event.output_index; + // A malformed or absent `output_index` must not collapse to 0: two such events would then + // both synthesize `msg_ocx_0`, and duplicate ids are the very thing this backfill exists to + // prevent. Fall back to a per-process counter so the synthesized id stays unique. Position + // is not recoverable in that case, but a unique id is what strict decoders require, and a + // well-formed stream still gets the stable index-derived id. + const item = typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0 + ? backfillOutputItem(event.item, { kind: "index", index: rawIndex }) + : backfillOutputItem(event.item, nextSyntheticItemSlot()); if (item !== event.item) { next = { ...next, item }; changed = true; diff --git a/tests/responses-field-backfill.test.ts b/tests/responses-field-backfill.test.ts index 8071323e5a..f6fb63bae3 100644 --- a/tests/responses-field-backfill.test.ts +++ b/tests/responses-field-backfill.test.ts @@ -170,4 +170,254 @@ describe("responses-field-backfill", () => { expect(result.output[0].content[3].annotations).toBe("not-an-array"); expect(result.output[0].content[4].annotations).toEqual({ unexpected: true }); }); + + test("backfills missing ids on response.completed output items", () => { + const event = { + type: "response.completed", + sequence_number: 42, + response: { + id: "resp_1", + object: "response", + status: "completed", + output: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] }, + { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hello" }], + }, + { + type: "function_call", + call_id: "call_1", + name: "todo_write", + arguments: "{}", + }, + ], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.response.output[0].id).toBe("rs_ocx_0"); + expect(parsed.response.output[1].id).toBe("msg_ocx_1"); + expect(parsed.response.output[2].id).toBe("fc_ocx_2"); + }); + + test("preserves existing item ids", () => { + const event = { + type: "response.completed", + sequence_number: 42, + response: { + id: "resp_1", + object: "response", + status: "completed", + output: [ + { type: "message", id: "msg_real", role: "assistant", content: [{ type: "output_text", text: "hi" }] }, + ], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.response.output[0].id).toBe("msg_real"); + }); + + test("uses output_index when backfilling output_item.done id", () => { + const event = { + type: "response.output_item.done", + output_index: 3, + item: { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hi" }], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.item.id).toBe("msg_ocx_3"); + }); + + test("falls back to item_ prefix for inherited type names", () => { + const event = { + type: "response.output_item.done", + output_index: 0, + item: { + type: "toString", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hi" }], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + expect(parsed.item.id).toBe("item_ocx_0"); + }); + + test("an invalid output_index still yields a well-formed synthesized id", () => { + for (const badIndex of [-1, 1.5, NaN, Infinity, "0", null, undefined]) { + const event = { + type: "response.output_item.done", + output_index: badIndex, + item: { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hi" }], + }, + }; + const [out] = apply(sseBlock(event)); + const parsed = parseData([out])[0]; + // The fallback carries its own namespace so it can never equal an index-derived id. + expect(parsed.item.id).toMatch(/^msg_ocx_fallback_\d+$/); + } + }); + + test("two items with an unusable output_index do not collide on one id", () => { + // Collapsing an unusable index to 0 would synthesize `msg_ocx_0` twice, which is the + // duplicate-id defect this backfill exists to prevent. Position is unrecoverable here; + // uniqueness is not optional. + const event = (text: string) => ({ + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text }], + }, + }); + const first = parseData(apply(sseBlock(event("one"))))[0]; + const second = parseData(apply(sseBlock(event("two"))))[0]; + + expect(first.item.id).toMatch(/^msg_ocx_fallback_\d+$/); + expect(second.item.id).toMatch(/^msg_ocx_fallback_\d+$/); + expect(first.item.id).not.toBe(second.item.id); + }); + + test("a well-formed output_index still produces the stable index-derived id", () => { + const event = { + type: "response.output_item.done", + output_index: 3, + item: { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hi" }], + }, + }; + // Stability across events referencing the same item is the whole point of index-derivation, + // so the fallback must not leak into the well-formed path. + expect(parseData(apply(sseBlock(event)))[0].item.id).toBe("msg_ocx_3"); + expect(parseData(apply(sseBlock(event)))[0].item.id).toBe("msg_ocx_3"); + }); + + test("backfillResponsesFieldsJson backfills missing ids on output items", () => { + const response = { + id: "resp_1", + object: "response", + status: "completed", + output: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] }, + { + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "hello" }], + }, + { + type: "function_call", + call_id: "call_1", + name: "todo_write", + arguments: "{}", + }, + ], + }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as typeof response; + expect(result.output[0].id).toBe("rs_ocx_0"); + expect(result.output[1].id).toBe("msg_ocx_1"); + expect(result.output[2].id).toBe("fc_ocx_2"); + }); + + test("backfillResponsesFieldsJson preserves existing item ids", () => { + const response = { + id: "resp_1", + object: "response", + status: "completed", + output: [ + { type: "message", id: "msg_real", role: "assistant", content: [{ type: "output_text", text: "hi" }] }, + ], + }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as typeof response; + expect(result.output[0].id).toBe("msg_real"); + }); + + test("the canonical image_generation_call type gets its own prefix", () => { + const response = { + id: "resp_1", + object: "response", + status: "completed", + output: [{ type: "image_generation_call", result: "..." }], + }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { + output: { id: string }[]; + }; + // The wire type is `image_generation_call`; keying the table on the short spelling alone + // silently demoted every real one to the generic `item_` prefix. + expect(result.output[0]!.id).toBe("ig_ocx_0"); + }); + + // A malformed `output_index` falls back to a counter. While that counter lived in the same + // numeric namespace as real indexes, a response whose real index reached the counter's base + // produced the SAME id as a fallback — a duplicate, which is the one thing this backfill + // exists to prevent. + test("a fallback id cannot collide with any index-derived id", () => { + const malformed = parseData(apply(sseBlock({ + type: "response.output_item.added", + output_index: "not-a-number", + item: { type: "message", role: "assistant", content: [] }, + }))); + const fallbackId = (malformed[0]!.item as { id: string }).id; + + // Every index-derived id is `msg_ocx_`; the fallback namespace is lexically + // disjoint from it, so no integer index can ever produce this string. + expect(fallbackId).toMatch(/^msg_ocx_fallback_\d+$/); + expect(fallbackId).not.toMatch(/^msg_ocx_\d+$/); + + for (const index of [0, 1, 1_000_000, 1_000_001, 1_000_002]) { + const derived = parseData(apply(sseBlock({ + type: "response.output_item.added", + output_index: index, + item: { type: "message", role: "assistant", content: [] }, + }))); + expect((derived[0]!.item as { id: string }).id).not.toBe(fallbackId); + } + }); + + test("consecutive malformed indexes still get distinct ids", () => { + const ids = [0, 1].map(() => { + const out = parseData(apply(sseBlock({ + type: "response.output_item.added", + item: { type: "message", role: "assistant", content: [] }, + }))); + return (out[0]!.item as { id: string }).id; + }); + expect(new Set(ids).size).toBe(2); + }); + + // `compaction` is the /v1/responses/compact wire format, not a Responses output item: it + // carries no id in that contract, and clients compare the body exactly. Synthesizing an id + // here changed a response that had nothing to do with strict Responses decoding — a defect + // that only appeared once this backfill and the compact endpoint were on the same tree. + test("a compaction item is returned byte-for-byte", () => { + const response = { + id: "resp_1", + object: "response", + status: "completed", + output: [{ type: "compaction", encrypted_content: "gAAAAAB-test-opaque" }], + }; + const result = JSON.parse(backfillResponsesFieldsJson(JSON.stringify(response))) as { + output: Record[]; + }; + expect(result.output[0]).toEqual({ type: "compaction", encrypted_content: "gAAAAAB-test-opaque" }); + expect(result.output[0]).not.toHaveProperty("id"); + }); });