//! OpenAI Responses API JSON serialization and streaming-event parsing. //! //! The Responses API (`POST /responses`) backs the ChatGPT-subscription Codex //! provider. Its wire shape differs from Chat Completions: //! //! - The system prompt rides in a top-level `instructions` string. //! - History is an `input` array of items: `{role, content:[{type, text}]}` //! messages, `{type:"function_call", call_id, name, arguments}` for //! assistant tool calls, and `{type:"function_call_output", call_id, //! output}` for tool results. //! - Tools are flat: `{type:"function", name, description, parameters}`. //! - Reasoning is requested via `{reasoning:{effort, summary}}` and //! `include:["reasoning.encrypted_content"]`; `store:false` keeps the //! exchange stateless. //! - Streaming uses typed SSE events (`response.output_text.delta`, //! `response.function_call_arguments.delta`, `response.completed`, …) //! rather than Chat Completions' `choices[].delta`. //! //! References: OpenAI Responses API streaming docs and the open-source Codex //! client's request transformer. const std = @import("std"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const conversation = @import("conversation.zig"); const config_mod = @import("config.zig"); const tool_registry_mod = @import("tool_registry.zig"); // =========================================================================== // Request serialization // =========================================================================== /// Serialize a Conversation into a `/responses` request body. Caller owns the /// returned slice. pub fn serializeRequest( allocator: Allocator, cfg: *const config_mod.OpenAIResponsesConfig, conv: *const conversation.Conversation, tools: *const tool_registry_mod.ToolRegistry, ) ![]u8 { var aw: Writer.Allocating = .init(allocator); errdefer aw.deinit(); var s: std.json.Stringify = .{ .writer = &aw.writer }; try s.beginObject(); try s.objectField("model"); try s.write(cfg.model); try s.objectField("stream"); try s.write(true); // Stateless: we replay full history each turn (no server-side state). try s.objectField("store"); try s.write(false); try s.objectField("max_output_tokens"); try s.write(cfg.max_tokens); // Carry encrypted reasoning so multi-turn reasoning can continue without // server-side storage. try s.objectField("include"); try s.beginArray(); try s.write("reasoning.encrypted_content"); try s.endArray(); switch (cfg.reasoning) { .default => {}, // The Codex backend does not accept "none"; the lowest real effort is // "low". `.off`/`.minimal` map to "low". .off, .minimal, .low => try writeReasoning(&s, "low"), .medium => try writeReasoning(&s, "medium"), .high => try writeReasoning(&s, "high"), } // System prompt → `instructions` (joined with blank lines). var sys_blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items); defer sys_blocks.deinit(allocator); if (sys_blocks.items.len > 0) { var instr: std.ArrayList(u8) = .empty; defer instr.deinit(allocator); for (sys_blocks.items, 0..) |text, i| { if (i != 0) try instr.appendSlice(allocator, "\n\n"); try instr.appendSlice(allocator, text); } try s.objectField("instructions"); try s.write(instr.items); } if (tools.count() > 0) { try s.objectField("tools"); try s.beginArray(); var it = tools.toolsForLLM(); while (it.next()) |t| { try s.beginObject(); try s.objectField("type"); try s.write("function"); try s.objectField("name"); try s.write(t.decl.name); // already wire-encoded try s.objectField("description"); try s.write(t.decl.description); try s.objectField("parameters"); try writeRawJson(&s, t.decl.schema_json); try s.endObject(); } try s.endArray(); } try s.objectField("input"); try s.beginArray(); for (conversation.activeMessageWindow(conv.messages.items)) |msg| { if (msg.role == .system) continue; try writeInputForMessage(&s, msg, allocator); } try s.endArray(); try s.endObject(); return try aw.toOwnedSlice(); } fn writeReasoning(s: *std.json.Stringify, effort: []const u8) !void { try s.objectField("reasoning"); try s.beginObject(); try s.objectField("effort"); try s.write(effort); try s.objectField("summary"); try s.write("auto"); try s.endObject(); } /// Emit the `input` item(s) for one conversation message. fn writeInputForMessage(s: *std.json.Stringify, msg: conversation.Message, allocator: Allocator) !void { switch (msg.role) { .system => {}, .user => { // Tool results fan out into `function_call_output` items; any // plain text becomes a `user` message. var has_tool_result = false; for (msg.content.items) |b| { if (b == .ToolResult) has_tool_result = true; } if (has_tool_result) { for (msg.content.items) |block| { if (block != .ToolResult) continue; const tr = block.ToolResult; try s.beginObject(); try s.objectField("type"); try s.write("function_call_output"); try s.objectField("call_id"); try s.write(tr.tool_use_id); try s.objectField("output"); var tbuf: std.ArrayList(u8) = .empty; defer tbuf.deinit(allocator); try tr.appendTextInto(allocator, &tbuf); try s.write(tbuf.items); try s.endObject(); } } // Plain user text (skip if the message was purely tool results). var text_buf: std.ArrayList(u8) = .empty; defer text_buf.deinit(allocator); try concatTextBlocks(msg.content.items, &text_buf, allocator); if (text_buf.items.len > 0) { try writeRoleMessage(s, "user", "input_text", text_buf.items); } }, .assistant => { // Assistant text first (as an output_text message), then each // tool call as a `function_call` item. var text_buf: std.ArrayList(u8) = .empty; defer text_buf.deinit(allocator); try concatTextBlocks(msg.content.items, &text_buf, allocator); if (text_buf.items.len > 0) { try writeRoleMessage(s, "assistant", "output_text", text_buf.items); } for (msg.content.items) |block| { if (block != .ToolUse) continue; const tu = block.ToolUse; try s.beginObject(); try s.objectField("type"); try s.write("function_call"); try s.objectField("call_id"); try s.write(tu.id); try s.objectField("name"); var name_buf: [tool_registry_mod.max_wire_name_len]u8 = undefined; try s.write(tool_registry_mod.encodeName(&name_buf, tu.name)); try s.objectField("arguments"); try s.write(tu.input.items); try s.endObject(); } }, } } fn writeRoleMessage(s: *std.json.Stringify, role: []const u8, content_type: []const u8, text: []const u8) !void { try s.beginObject(); try s.objectField("role"); try s.write(role); try s.objectField("content"); try s.beginArray(); try s.beginObject(); try s.objectField("type"); try s.write(content_type); try s.objectField("text"); try s.write(text); try s.endObject(); try s.endArray(); try s.endObject(); } fn concatTextBlocks( blocks: []const conversation.ContentBlock, out: *std.ArrayList(u8), allocator: Allocator, ) !void { for (blocks) |block| { switch (block) { .Text => |tb| try out.appendSlice(allocator, tb.items), .CompactionSummary => |cs| try out.appendSlice(allocator, cs.text.items), else => {}, } } } fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), raw, .{}) catch { try s.beginObject(); try s.endObject(); return; }; try s.write(parsed.value); } // =========================================================================== // Streaming event parsing // =========================================================================== /// The kinds of streaming event this provider acts on. Everything else is /// ignored (`.other`). pub const EventKind = enum { output_item_added, output_text_delta, reasoning_summary_delta, function_call_arguments_delta, output_item_done, completed, failed, err, other, }; /// A parsed Responses streaming event. Slices borrow from `parsed`. pub const StreamEvent = struct { parsed: std.json.Parsed(std.json.Value), kind: EventKind, /// `output_text`/`reasoning_summary` delta, or function-call argument /// fragment. delta: ?[]const u8 = null, /// Item id (`response.output_item.added/done`, function-call argument /// deltas reference `item_id`). item_id: ?[]const u8 = null, /// Item type on add/done: "message" | "function_call" | "reasoning". item_type: ?[]const u8 = null, /// Function-call identity (on `output_item.added`/`done`). call_id: ?[]const u8 = null, name: ?[]const u8 = null, /// Full arguments string on `output_item.done` for a function_call. arguments: ?[]const u8 = null, /// Error/failure message (`error`, `response.failed`). error_message: ?[]const u8 = null, /// Usage on `response.completed`. usage: ?Usage = null, pub const Usage = struct { input_tokens: u64 = 0, output_tokens: u64 = 0, cached_tokens: u64 = 0, reasoning_tokens: u64 = 0, }; pub fn deinit(self: *StreamEvent) void { self.parsed.deinit(); } }; /// Parse one SSE event payload (the JSON after `data: `). The caller must keep /// the returned value alive while reading its slices, then `deinit` it. pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !StreamEvent { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{}); errdefer parsed.deinit(); var ev: StreamEvent = .{ .parsed = parsed, .kind = .other }; const root = parsed.value; if (root != .object) return ev; const obj = root.object; const type_str = strField(obj, "type") orelse { ev.parsed = parsed; return ev; }; if (std.mem.eql(u8, type_str, "response.output_text.delta")) { ev.kind = .output_text_delta; ev.delta = strField(obj, "delta"); ev.item_id = strField(obj, "item_id"); } else if (std.mem.eql(u8, type_str, "response.reasoning_summary_text.delta")) { ev.kind = .reasoning_summary_delta; ev.delta = strField(obj, "delta"); ev.item_id = strField(obj, "item_id"); } else if (std.mem.eql(u8, type_str, "response.function_call_arguments.delta")) { ev.kind = .function_call_arguments_delta; ev.delta = strField(obj, "delta"); ev.item_id = strField(obj, "item_id"); } else if (std.mem.eql(u8, type_str, "response.output_item.added")) { ev.kind = .output_item_added; readItem(obj, &ev); } else if (std.mem.eql(u8, type_str, "response.output_item.done")) { ev.kind = .output_item_done; readItem(obj, &ev); } else if (std.mem.eql(u8, type_str, "response.completed")) { ev.kind = .completed; readUsage(obj, &ev); } else if (std.mem.eql(u8, type_str, "response.failed") or std.mem.eql(u8, type_str, "response.incomplete")) { ev.kind = .failed; ev.error_message = readResponseError(obj); } else if (std.mem.eql(u8, type_str, "error")) { ev.kind = .err; ev.error_message = strField(obj, "message") orelse "stream error"; } ev.parsed = parsed; return ev; } fn readItem(obj: std.json.ObjectMap, ev: *StreamEvent) void { const item = obj.get("item") orelse return; if (item != .object) return; const io = item.object; ev.item_id = strField(io, "id"); ev.item_type = strField(io, "type"); ev.call_id = strField(io, "call_id"); ev.name = strField(io, "name"); ev.arguments = strField(io, "arguments"); } fn readUsage(obj: std.json.ObjectMap, ev: *StreamEvent) void { const resp = obj.get("response") orelse return; if (resp != .object) return; const u = resp.object.get("usage") orelse return; if (u != .object) return; var usage: StreamEvent.Usage = .{}; usage.input_tokens = u64Field(u.object, "input_tokens"); usage.output_tokens = u64Field(u.object, "output_tokens"); usage.reasoning_tokens = blk: { const otd = u.object.get("output_tokens_details") orelse break :blk 0; if (otd != .object) break :blk 0; break :blk u64Field(otd.object, "reasoning_tokens"); }; usage.cached_tokens = blk: { const itd = u.object.get("input_tokens_details") orelse break :blk 0; if (itd != .object) break :blk 0; break :blk u64Field(itd.object, "cached_tokens"); }; ev.usage = usage; } fn readResponseError(obj: std.json.ObjectMap) ?[]const u8 { const resp = obj.get("response") orelse return null; if (resp != .object) return null; if (resp.object.get("error")) |e| { if (e == .object) return strField(e.object, "message"); } if (resp.object.get("incomplete_details")) |d| { if (d == .object) return strField(d.object, "reason"); } return null; } fn strField(obj: std.json.ObjectMap, name: []const u8) ?[]const u8 { const v = obj.get(name) orelse return null; return if (v == .string) v.string else null; } fn u64Field(obj: std.json.ObjectMap, name: []const u8) u64 { const v = obj.get(name) orelse return 0; if (v != .integer or v.integer < 0) return 0; return @intCast(v.integer); } // =========================================================================== // Tests // =========================================================================== const testing = std.testing; fn testConfig(model: []const u8) config_mod.OpenAIResponsesConfig { return .{ .api_key = "k", .base_url = "u", .model = model }; } fn emptyTools() tool_registry_mod.ToolRegistry { return tool_registry_mod.ToolRegistry.init(testing.allocator); } fn addUserText(conv: *conversation.Conversation, text: []const u8) !void { const tb = try conversation.textualBlockFromSlice(conv.allocator, text); var block: conversation.ContentBlock = .{ .Text = tb }; errdefer block.deinit(conv.allocator); try conv.addUserMessage(&.{block}); } test "responses serializeRequest - instructions, input, store/include" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addSystemMessage("You are Codex."); try addUserText(&conv, "Hello!"); var cfg = testConfig("gpt-5.1-codex"); cfg.reasoning = .high; var tools = emptyTools(); defer tools.deinit(); const body = try serializeRequest(allocator, &cfg, &conv, &tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); const root = parsed.value.object; try testing.expectEqualStrings("gpt-5.1-codex", root.get("model").?.string); try testing.expect(root.get("stream").?.bool); try testing.expect(!root.get("store").?.bool); try testing.expectEqualStrings("You are Codex.", root.get("instructions").?.string); try testing.expectEqualStrings("reasoning.encrypted_content", root.get("include").?.array.items[0].string); try testing.expectEqualStrings("high", root.get("reasoning").?.object.get("effort").?.string); const input = root.get("input").?.array.items; try testing.expectEqual(@as(usize, 1), input.len); try testing.expectEqualStrings("user", input[0].object.get("role").?.string); const part = input[0].object.get("content").?.array.items[0].object; try testing.expectEqualStrings("input_text", part.get("type").?.string); try testing.expectEqualStrings("Hello!", part.get("text").?.string); } test "responses serializeRequest - tools are flat function items" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try addUserText(&conv, "go"); var tools = emptyTools(); defer tools.deinit(); try tools.register(.{ .decl = .{ .name = "echo", .description = "Echo.", .schema_json = "{\"type\":\"object\"}" }, .ctx = undefined, .vtable = &NoopToolVT.v, }); const cfg = testConfig("gpt-5.1-codex"); const body = try serializeRequest(allocator, &cfg, &conv, &tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); const tool0 = parsed.value.object.get("tools").?.array.items[0].object; // Flat shape: name/description/parameters directly on the tool object. try testing.expectEqualStrings("function", tool0.get("type").?.string); try testing.expectEqualStrings("echo", tool0.get("name").?.string); try testing.expectEqualStrings("Echo.", tool0.get("description").?.string); try testing.expect(tool0.get("parameters").? == .object); } test "responses serializeRequest - assistant tool_use + tool result round-trip" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); const id = try allocator.dupe(u8, "call_1"); const name = try allocator.dupe(u8, "echo"); var args: conversation.TextualBlock = .empty; try args.appendSlice(allocator, "{\"m\":\"hi\"}"); try conv.addAssistantMessage(&.{ .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling") }, .{ .ToolUse = .{ .id = id, .name = name, .input = args } }, }, null); const rid = try allocator.dupe(u8, "call_1"); var parts: std.ArrayList(conversation.ResultPartStored) = .empty; try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "42") }); var content: std.ArrayList(conversation.ContentBlock) = .empty; try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = rid, .parts = parts } }); try conv.messages.append(allocator, .{ .role = .user, .content = content }); var tools = emptyTools(); defer tools.deinit(); const cfg = testConfig("gpt-5.1-codex"); const body = try serializeRequest(allocator, &cfg, &conv, &tools); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); const input = parsed.value.object.get("input").?.array.items; // assistant message (text) + function_call + function_call_output = 3. try testing.expectEqual(@as(usize, 3), input.len); try testing.expectEqualStrings("assistant", input[0].object.get("role").?.string); try testing.expectEqualStrings("function_call", input[1].object.get("type").?.string); try testing.expectEqualStrings("call_1", input[1].object.get("call_id").?.string); try testing.expectEqualStrings("echo", input[1].object.get("name").?.string); try testing.expectEqualStrings("function_call_output", input[2].object.get("type").?.string); try testing.expectEqualStrings("call_1", input[2].object.get("call_id").?.string); try testing.expectEqualStrings("42", input[2].object.get("output").?.string); } test "responses parseStreamEvent - output_text delta" { const allocator = testing.allocator; var ev = try parseStreamEvent(allocator, \\{"type":"response.output_text.delta","item_id":"msg_1","delta":"Hi"} ); defer ev.deinit(); try testing.expectEqual(EventKind.output_text_delta, ev.kind); try testing.expectEqualStrings("Hi", ev.delta.?); } test "responses parseStreamEvent - function_call item added/done + args delta" { const allocator = testing.allocator; var added = try parseStreamEvent(allocator, \\{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"echo"}} ); defer added.deinit(); try testing.expectEqual(EventKind.output_item_added, added.kind); try testing.expectEqualStrings("function_call", added.item_type.?); try testing.expectEqualStrings("call_9", added.call_id.?); try testing.expectEqualStrings("echo", added.name.?); var d = try parseStreamEvent(allocator, \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"x\":1}"} ); defer d.deinit(); try testing.expectEqual(EventKind.function_call_arguments_delta, d.kind); try testing.expectEqualStrings("fc_1", d.item_id.?); try testing.expectEqualStrings("{\"x\":1}", d.delta.?); } test "responses parseStreamEvent - completed usage" { const allocator = testing.allocator; var ev = try parseStreamEvent(allocator, \\{"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20,"input_tokens_details":{"cached_tokens":80},"output_tokens_details":{"reasoning_tokens":8}}}} ); defer ev.deinit(); try testing.expectEqual(EventKind.completed, ev.kind); try testing.expectEqual(@as(u64, 100), ev.usage.?.input_tokens); try testing.expectEqual(@as(u64, 20), ev.usage.?.output_tokens); try testing.expectEqual(@as(u64, 80), ev.usage.?.cached_tokens); try testing.expectEqual(@as(u64, 8), ev.usage.?.reasoning_tokens); } test "responses parseStreamEvent - error + failed" { const allocator = testing.allocator; var e = try parseStreamEvent(allocator, \\{"type":"error","message":"boom"} ); defer e.deinit(); try testing.expectEqual(EventKind.err, e.kind); try testing.expectEqualStrings("boom", e.error_message.?); var f = try parseStreamEvent(allocator, \\{"type":"response.failed","response":{"error":{"message":"bad"}}} ); defer f.deinit(); try testing.expectEqual(EventKind.failed, f.kind); try testing.expectEqualStrings("bad", f.error_message.?); } const tool_mod = @import("tool.zig"); const NoopToolVT = struct { fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts { return error.NotImplementedInTest; } fn deinit_(_: *anyopaque, _: Allocator) void {} const v: tool_mod.Tool.VTable = .{ .invoke = invoke, .deinit = deinit_ }; };