summaryrefslogtreecommitdiff
path: root/libpanto/src/openai_chat_json.zig
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-25 13:24:02 -0700
committerT <t@tjp.lol>2026-05-25 15:27:11 -0700
commitdf2edee86eec2a8deb0ad57b5d20552199c12b65 (patch)
treec1e962e9b0bdf25572929e3efedc3b1915be853f /libpanto/src/openai_chat_json.zig
parente8272efd9f71ad27a0e62b6b3fa3d13e99a6736f (diff)
phase 1 done
Diffstat (limited to 'libpanto/src/openai_chat_json.zig')
-rw-r--r--libpanto/src/openai_chat_json.zig324
1 files changed, 324 insertions, 0 deletions
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.?);
+}