//! OpenAI Chat Completions JSON serialization and parsing. //! //! Two responsibilities: //! 1. Serialize a `Conversation` into the OpenAI Chat Completions request body. //! 2. Parse one streaming SSE event's JSON payload into a strongly-typed //! `StreamDelta` that the provider can consume. 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"); /// A single parsed streaming chunk. Fields are populated only when present /// in the wire payload; null fields signal "not in this chunk". /// /// `content` and `reasoning_content` slices are borrowed from the parsed /// JSON value, which is owned by the caller's `std.json.Parsed`. pub const StreamDelta = struct { role: ?[]const u8 = null, content: ?[]const u8 = null, reasoning_content: ?[]const u8 = null, finish_reason: ?[]const u8 = null, }; /// Serialize a Conversation into a `chat/completions` request body. /// /// The caller owns the returned slice (allocated with `allocator`). pub fn serializeRequest( allocator: Allocator, cfg: *const config_mod.Config, conv: *const conversation.Conversation, ) ![]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); switch (cfg.reasoning) { .default => {}, .off => { try s.objectField("reasoning_effort"); try s.write("none"); }, .minimal, .low, .medium, .high => |eff| { try s.objectField("reasoning_effort"); try s.write(@tagName(eff)); }, } try s.objectField("messages"); try s.beginArray(); for (conv.messages.items) |msg| { try writeMessage(&s, msg, allocator); } try s.endArray(); try s.endObject(); return try aw.toOwnedSlice(); } fn writeMessage(s: *std.json.Stringify, msg: conversation.Message, allocator: Allocator) !void { try s.beginObject(); try s.objectField("role"); try s.write(@tagName(msg.role)); // All roles flatten to a plain `content` string in outbound requests. // Thinking blocks are intentionally dropped from history: the openai_chat // dialect has no portable way to round-trip them (OpenAI/DeepSeek strip; // OpenRouter/NanoGPT want a `reasoning` field instead of an inline block; // none accept the `{"type":"thinking",...}` shape). Preserving reasoning // across turns will land with tool-use in a later phase. try s.objectField("content"); var buf: std.ArrayList(u8) = .empty; defer buf.deinit(allocator); try concatTextBlocks(msg.content.items, &buf, allocator); try s.write(buf.items); 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), // Thinking: dropped. ToolUse/ToolResult: phase 3+. else => {}, } } } /// Parse a single SSE event payload (the JSON object that follows "data: "). /// /// Returns a `StreamDelta` borrowed from `parsed`. The caller must keep /// `parsed` alive for as long as the delta's slices are in use, then call /// `parsed.deinit()`. pub const ParsedDelta = struct { parsed: std.json.Parsed(std.json.Value), delta: StreamDelta, pub fn deinit(self: *ParsedDelta) void { self.parsed.deinit(); } }; pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedDelta { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{}); errdefer parsed.deinit(); var delta: StreamDelta = .{}; const root = parsed.value; if (root != .object) return .{ .parsed = parsed, .delta = delta }; const choices_v = root.object.get("choices") orelse return .{ .parsed = parsed, .delta = delta }; if (choices_v != .array or choices_v.array.items.len == 0) { return .{ .parsed = parsed, .delta = delta }; } const choice = choices_v.array.items[0]; if (choice != .object) return .{ .parsed = parsed, .delta = delta }; if (choice.object.get("finish_reason")) |fr| { if (fr == .string) delta.finish_reason = fr.string; } if (choice.object.get("delta")) |d| { if (d == .object) { if (d.object.get("role")) |r| { if (r == .string) delta.role = r.string; } if (d.object.get("content")) |c| { if (c == .string) delta.content = c.string; } // Reasoning content lives under one of these names depending on // the provider. We accept either. if (d.object.get("reasoning_content")) |rc| { if (rc == .string) delta.reasoning_content = rc.string; } else if (d.object.get("reasoning")) |rc| { if (rc == .string) delta.reasoning_content = rc.string; } } } return .{ .parsed = parsed, .delta = delta }; } // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- const testing = std.testing; fn testConfig(model: []const u8) config_mod.Config { return .{ .api_style = .openai_chat, .api_key = "k", .base_url = "u", .model = model, }; } test "serializeRequest - system + user" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addSystemMessage("You are helpful."); try conv.addUserMessage("Hello!"); const cfg = testConfig("gpt-4o"); const body = try serializeRequest(allocator, &cfg, &conv); 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-4o", root.get("model").?.string); try testing.expect(root.get("stream").?.bool); // reasoning_effort is omitted when set to .default. try testing.expect(root.get("reasoning_effort") == null); const msgs = root.get("messages").?.array.items; try testing.expectEqual(@as(usize, 2), msgs.len); try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string); try testing.expectEqualStrings("You are helpful.", msgs[0].object.get("content").?.string); try testing.expectEqualStrings("user", msgs[1].object.get("role").?.string); try testing.expectEqualStrings("Hello!", msgs[1].object.get("content").?.string); } test "serializeRequest - assistant Thinking blocks are stripped from outbound history" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addAssistantMessage(&.{ .{ .Thinking = try conversation.textualBlockFromSlice(allocator, "thinking step") }, .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer here") }, }); const cfg = testConfig("gpt-4o"); const body = try serializeRequest(allocator, &cfg, &conv); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); const msg = parsed.value.object.get("messages").?.array.items[0]; try testing.expectEqualStrings("assistant", msg.object.get("role").?.string); // Content is a flat string, only the Text block survives. const content = msg.object.get("content").?.string; try testing.expectEqualStrings("answer here", content); } test "serializeRequest - reasoning effort level included when set" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addUserMessage("Hi"); var cfg = testConfig("gpt-4o"); cfg.reasoning = .high; const body = try serializeRequest(allocator, &cfg, &conv); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); try testing.expectEqualStrings( "high", parsed.value.object.get("reasoning_effort").?.string, ); } test "serializeRequest - reasoning .off sends \"none\"" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addUserMessage("Hi"); var cfg = testConfig("gpt-4o"); cfg.reasoning = .off; const body = try serializeRequest(allocator, &cfg, &conv); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); try testing.expectEqualStrings( "none", parsed.value.object.get("reasoning_effort").?.string, ); } test "parseStreamEvent - role only" { const allocator = testing.allocator; const payload = \\{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} ; var pd = try parseStreamEvent(allocator, payload); defer pd.deinit(); try testing.expectEqualStrings("assistant", pd.delta.role.?); try testing.expect(pd.delta.content == null); try testing.expect(pd.delta.finish_reason == null); } test "parseStreamEvent - content delta" { const allocator = testing.allocator; const payload = \\{"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]} ; var pd = try parseStreamEvent(allocator, payload); defer pd.deinit(); try testing.expectEqualStrings("Hello", pd.delta.content.?); } test "parseStreamEvent - finish_reason stop" { const allocator = testing.allocator; const payload = \\{"choices":[{"delta":{},"finish_reason":"stop"}]} ; var pd = try parseStreamEvent(allocator, payload); defer pd.deinit(); try testing.expectEqualStrings("stop", pd.delta.finish_reason.?); try testing.expect(pd.delta.content == null); } test "parseStreamEvent - reasoning_content" { const allocator = testing.allocator; const payload = \\{"choices":[{"delta":{"reasoning_content":"hmm"}}]} ; var pd = try parseStreamEvent(allocator, payload); defer pd.deinit(); try testing.expectEqualStrings("hmm", pd.delta.reasoning_content.?); }