From df2edee86eec2a8deb0ad57b5d20552199c12b65 Mon Sep 17 00:00:00 2001 From: T Date: Mon, 25 May 2026 13:24:02 -0700 Subject: phase 1 done --- libpanto/src/openai_chat_json.zig | 324 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 libpanto/src/openai_chat_json.zig (limited to 'libpanto/src/openai_chat_json.zig') diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig new file mode 100644 index 0000000..79cccac --- /dev/null +++ b/libpanto/src/openai_chat_json.zig @@ -0,0 +1,324 @@ +//! 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.?); +} -- cgit v1.3