summaryrefslogtreecommitdiff
path: root/src/openai_chat_json.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:26:32 -0600
committert <t@tjp.lol>2026-07-07 11:26:45 -0600
commitf83578fdc9264019a1a1cef8c5484a161167d3dd (patch)
tree888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/openai_chat_json.zig
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/openai_chat_json.zig')
-rw-r--r--src/openai_chat_json.zig1067
1 files changed, 1067 insertions, 0 deletions
diff --git a/src/openai_chat_json.zig b/src/openai_chat_json.zig
new file mode 100644
index 0000000..f00c97f
--- /dev/null
+++ b/src/openai_chat_json.zig
@@ -0,0 +1,1067 @@
+//! 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");
+const tool_registry_mod = @import("tool_registry.zig");
+const writeRawJson = @import("provider.zig").writeRawJson;
+
+/// 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,
+ tool_calls: []const ToolCallDelta = &.{},
+ /// Mid-stream error reported by the provider. OpenAI-compatible
+ /// endpoints sometimes return HTTP 200 with `data: {"error":{...}}`
+ /// embedded in the SSE stream instead of a non-2xx response (notably
+ /// some OpenRouter / MiniMax / DeepSeek paths, and occasionally OpenAI
+ /// itself on transient overload). When present, the provider must
+ /// treat the turn as failed.
+ error_message: ?[]const u8 = null,
+ error_type: ?[]const u8 = null,
+ /// Token usage from the final chunk's top-level `usage` block.
+ /// Only present on the final chunk when the request was sent with
+ /// `stream_options.include_usage: true`. Earlier chunks have null.
+ usage: ?StreamUsage = null,
+};
+
+/// Token usage payload from OpenAI's terminating SSE chunk. Field
+/// semantics:
+///
+/// - `prompt_tokens`: total input tokens, **including** cached tokens.
+/// - `completion_tokens`: output tokens (including reasoning tokens).
+/// - `cached_prompt_tokens`: subset of `prompt_tokens` served from
+/// the prompt cache (billed at a discount).
+/// - `reasoning_tokens`: subset of `completion_tokens` spent on
+/// internal reasoning (o-series models).
+///
+/// To map to `Usage`: `input = prompt_tokens - cached_prompt_tokens`,
+/// `cache_read = cached_prompt_tokens`, `output = completion_tokens`,
+/// `reasoning = reasoning_tokens`, `cache_write = 0` (OpenAI doesn't
+/// bill a cache-write premium).
+pub const StreamUsage = struct {
+ prompt_tokens: ?u64 = null,
+ completion_tokens: ?u64 = null,
+ cached_prompt_tokens: ?u64 = null,
+ reasoning_tokens: ?u64 = null,
+};
+
+/// A single entry from a streaming `tool_calls` array. Multiple parallel
+/// tool calls are distinguished by their `index`; identity fields (`id`,
+/// `name`) typically arrive only on the first delta for each index, while
+/// `arguments` arrives incrementally across many deltas.
+pub const ToolCallDelta = struct {
+ index: usize,
+ id: ?[]const u8 = null,
+ name: ?[]const u8 = null,
+ arguments: ?[]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.OpenAIChatConfig,
+ 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);
+
+ try s.objectField("max_completion_tokens");
+ try s.write(cfg.max_tokens);
+
+ // Ask for the final-chunk usage block. Without this the server
+ // sends `usage: null` and we can't stamp token counts on the
+ // session log. Most OpenAI-compatible proxies accept this; ones
+ // that don't will either ignore it or 400 — in the latter case
+ // the user has bigger problems than missing token counts.
+ try s.objectField("stream_options");
+ try s.beginObject();
+ try s.objectField("include_usage");
+ try s.write(true);
+ try s.endObject();
+
+ 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));
+ },
+ }
+
+ 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("function");
+ try s.beginObject();
+ try s.objectField("name");
+ // `t.decl.name` is already wire-encoded by `toolsForLLM`.
+ try s.write(t.decl.name);
+ try s.objectField("description");
+ try s.write(t.decl.description);
+ try s.objectField("parameters");
+ // Emit the tool's JSON Schema verbatim.
+ try writeRawJson(&s, t.decl.schema_json);
+ try s.endObject();
+ try s.endObject();
+ }
+ try s.endArray();
+ }
+
+ try s.objectField("messages");
+ try s.beginArray();
+ // Hoist the effective system prompt to the front as separate,
+ // individually-positioned `system` messages (one per surviving block,
+ // in derivation order). Keeping them distinct preserves block-level
+ // addressability for `/tree`-style truncation — we deliberately do NOT
+ // concatenate them the way Anthropic's single-string format forces.
+ var sys_blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items);
+ defer sys_blocks.deinit(allocator);
+ for (sys_blocks.items) |text| {
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("system");
+ try s.objectField("content");
+ try s.write(text);
+ try s.endObject();
+ }
+ // Then every non-system message, in its original order. If the
+ // conversation has been compacted, only the latest compaction summary
+ // and the messages after it are active; the superseded prefix is
+ // dropped.
+ for (conversation.activeMessageWindow(conv.messages.items)) |msg| {
+ if (msg.role == .system) continue;
+ try writeMessage(&s, msg, allocator);
+ }
+ try s.endArray();
+
+ try s.endObject();
+
+ return try aw.toOwnedSlice();
+}
+
+/// Emit one `Conversation.Message` as one or more wire-level messages.
+///
+/// OpenAI's wire format is awkward here: a single logical `user` turn that
+/// contains ToolResult blocks must be split into separate top-level
+/// `{"role":"tool",...}` messages (one per ToolResult). A single assistant
+/// turn that mixes Text and ToolUse becomes one assistant message with both
+/// a `content` string and a `tool_calls` array.
+fn writeMessage(s: *std.json.Stringify, msg: conversation.Message, allocator: Allocator) !void {
+ // User messages that carry ToolResult blocks fan out into one
+ // `role:"tool"` message per block. Any plain Text blocks in the same
+ // user message become a separate user message after the tool messages.
+ if (msg.role == .user) {
+ var has_tool_result = false;
+ for (msg.content.items) |b| {
+ if (b == .ToolResult) {
+ has_tool_result = true;
+ break;
+ }
+ }
+ if (has_tool_result) {
+ // OpenAI forbids images in `role:"tool"` messages. Each tool
+ // result emits a `role:"tool"` message carrying only its text
+ // (plus a short note when media is present), and any media
+ // rides along afterward in a synthetic `role:"user"` message.
+ var any_media = false;
+ for (msg.content.items) |block| {
+ if (block != .ToolResult) continue;
+ const tr = block.ToolResult;
+ if (tr.hasMedia()) any_media = true;
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("tool");
+ try s.objectField("tool_call_id");
+ try s.write(tr.tool_use_id);
+ try s.objectField("content");
+ var tbuf: std.ArrayList(u8) = .empty;
+ defer tbuf.deinit(allocator);
+ try tr.appendTextInto(allocator, &tbuf);
+ if (tr.hasMedia()) {
+ if (tbuf.items.len > 0) try tbuf.append(allocator, '\n');
+ try tbuf.appendSlice(allocator, "[attachment(s) provided in the following user message]");
+ }
+ try s.write(tbuf.items);
+ try s.endObject();
+ }
+ // Synthetic user message holding the media as image_url
+ // data-URL parts (OpenAI's only image channel).
+ if (any_media) {
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("user");
+ try s.objectField("content");
+ try s.beginArray();
+ for (msg.content.items) |block| {
+ if (block != .ToolResult) continue;
+ for (block.ToolResult.parts.items) |part| {
+ if (part != .media) continue;
+ const m = part.media;
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("image_url");
+ try s.objectField("image_url");
+ try s.beginObject();
+ try s.objectField("url");
+ var url_buf: std.ArrayList(u8) = .empty;
+ defer url_buf.deinit(allocator);
+ try url_buf.appendSlice(allocator, "data:");
+ try url_buf.appendSlice(allocator, m.media_type);
+ try url_buf.appendSlice(allocator, ";base64,");
+ try url_buf.appendSlice(allocator, m.data.items);
+ try s.write(url_buf.items);
+ try s.endObject();
+ try s.endObject();
+ }
+ }
+ try s.endArray();
+ try s.endObject();
+ }
+ // Trailing plain Text blocks (rare in practice) ride along as
+ // a follow-up user message so we don't lose them.
+ var has_text = false;
+ for (msg.content.items) |b| {
+ if (b == .Text) {
+ has_text = true;
+ break;
+ }
+ }
+ if (!has_text) return;
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("user");
+ 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();
+ return;
+ }
+ }
+
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write(@tagName(msg.role));
+
+ // Assistant messages may carry ToolUse blocks. The wire shape is a
+ // `tool_calls` array alongside `content`. OpenAI requires `content`
+ // to be either a string or null — we always emit a string (possibly
+ // empty) so JSON shape is predictable.
+ 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);
+
+ if (msg.role == .assistant) {
+ var n_tool_uses: usize = 0;
+ for (msg.content.items) |b| if (b == .ToolUse) {
+ n_tool_uses += 1;
+ };
+ if (n_tool_uses > 0) {
+ try s.objectField("tool_calls");
+ try s.beginArray();
+ for (msg.content.items) |block| {
+ if (block != .ToolUse) continue;
+ const tu = block.ToolUse;
+ try s.beginObject();
+ try s.objectField("id");
+ try s.write(tu.id);
+ try s.objectField("type");
+ try s.write("function");
+ try s.objectField("function");
+ try s.beginObject();
+ try s.objectField("name");
+ // Replayed assistant tool_use. The conversation stores the
+ // internal (dotted) name; encode `.` -> `__` so it matches
+ // what we advertise in `tools`.
+ 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");
+ // `arguments` is a string carrying JSON, per the OpenAI
+ // wire format — not a nested object.
+ try s.write(tu.input.items);
+ try s.endObject();
+ 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),
+ // A compaction summary is the synthetic seed text standing in
+ // for a compacted prefix; emit it as ordinary message text.
+ .CompactionSummary => |cs| try out.appendSlice(allocator, cs.text.items),
+ // Thinking, ToolUse, ToolResult: handled elsewhere or dropped.
+ 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,
+ /// Owned buffer holding the per-call deltas referenced by
+ /// `delta.tool_calls`. Freed by `deinit` along with `parsed`.
+ tool_calls_buf: ?[]ToolCallDelta = null,
+ allocator: Allocator,
+
+ pub fn deinit(self: *ParsedDelta) void {
+ if (self.tool_calls_buf) |b| self.allocator.free(b);
+ 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, .allocator = allocator };
+
+ // Top-level `error` field. Some providers (and OpenAI itself on rare
+ // mid-stream failures) emit `data: {"error":{"message":...,"type":...}}`
+ // with HTTP 200, so we look for this BEFORE the choices array.
+ // Top-level `usage` field on the terminating chunk. Independent of
+ // the (often empty) choices array.
+ if (root.object.get("usage")) |u| {
+ if (u == .object) {
+ var su: StreamUsage = .{};
+ su.prompt_tokens = readOptU64(u.object, "prompt_tokens");
+ su.completion_tokens = readOptU64(u.object, "completion_tokens");
+ if (u.object.get("prompt_tokens_details")) |ptd| {
+ if (ptd == .object) {
+ su.cached_prompt_tokens = readOptU64(ptd.object, "cached_tokens");
+ }
+ }
+ if (u.object.get("completion_tokens_details")) |ctd| {
+ if (ctd == .object) {
+ su.reasoning_tokens = readOptU64(ctd.object, "reasoning_tokens");
+ }
+ }
+ delta.usage = su;
+ }
+ }
+
+ if (root.object.get("error")) |e| {
+ switch (e) {
+ .object => |obj| {
+ if (obj.get("message")) |m| if (m == .string) {
+ delta.error_message = m.string;
+ };
+ if (obj.get("type")) |t| if (t == .string) {
+ delta.error_type = t.string;
+ };
+ },
+ .string => |s| {
+ // Some providers send a bare string. Surface it as the
+ // message so callers can still report something useful.
+ delta.error_message = s;
+ },
+ else => {},
+ }
+ }
+
+ const choices_v = root.object.get("choices") orelse return .{ .parsed = parsed, .delta = delta, .allocator = allocator };
+ if (choices_v != .array or choices_v.array.items.len == 0) {
+ return .{ .parsed = parsed, .delta = delta, .allocator = allocator };
+ }
+ const choice = choices_v.array.items[0];
+ if (choice != .object) return .{ .parsed = parsed, .delta = delta, .allocator = allocator };
+
+ if (choice.object.get("finish_reason")) |fr| {
+ if (fr == .string) delta.finish_reason = fr.string;
+ }
+
+ var tool_calls_buf: ?[]ToolCallDelta = null;
+ errdefer if (tool_calls_buf) |b| allocator.free(b);
+
+ 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;
+ }
+ if (d.object.get("tool_calls")) |tcs| {
+ if (tcs == .array and tcs.array.items.len > 0) {
+ const buf = try allocator.alloc(ToolCallDelta, tcs.array.items.len);
+ tool_calls_buf = buf;
+ for (tcs.array.items, 0..) |tc, i| {
+ var entry: ToolCallDelta = .{ .index = 0 };
+ if (tc == .object) {
+ if (tc.object.get("index")) |iv| {
+ if (iv == .integer and iv.integer >= 0) {
+ entry.index = @intCast(iv.integer);
+ }
+ }
+ if (tc.object.get("id")) |idv| {
+ if (idv == .string) entry.id = idv.string;
+ }
+ if (tc.object.get("function")) |fnv| {
+ if (fnv == .object) {
+ if (fnv.object.get("name")) |nv| {
+ if (nv == .string) entry.name = nv.string;
+ }
+ if (fnv.object.get("arguments")) |av| {
+ if (av == .string) entry.arguments = av.string;
+ }
+ }
+ }
+ }
+ buf[i] = entry;
+ }
+ delta.tool_calls = buf;
+ }
+ }
+ }
+ }
+
+ return .{
+ .parsed = parsed,
+ .delta = delta,
+ .tool_calls_buf = tool_calls_buf,
+ .allocator = allocator,
+ };
+}
+
+fn readOptU64(obj: std.json.ObjectMap, name: []const u8) ?u64 {
+ const v = obj.get(name) orelse return null;
+ if (v != .integer) return null;
+ if (v.integer < 0) return null;
+ return @intCast(v.integer);
+}
+
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+const testing = std.testing;
+
+fn testConfig(model: []const u8) config_mod.OpenAIChatConfig {
+ return .{
+ .api_key = "k",
+ .base_url = "u",
+ .model = model,
+ };
+}
+
+/// Caller deinits.
+fn emptyTools() tool_registry_mod.ToolRegistry {
+ return tool_registry_mod.ToolRegistry.init(testing.allocator);
+}
+
+/// Test helper: append a single-text user message. `addUserMessage` now
+/// takes a block slice (symmetric with `addAssistantMessage`); this wraps
+/// the common plain-text case the tests below use.
+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 "serializeRequest - system + user" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("You are helpful.");
+ try addUserText(&conv, "Hello!");
+
+ const cfg = testConfig("gpt-4o");
+ 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-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);
+ // No tools registered — the `tools` field must be omitted entirely.
+ try testing.expect(root.get("tools") == 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 - multiple system blocks hoisted as separate leading messages" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("seed");
+ try addUserText(&conv, "Hello!");
+ try conv.addSystemMessage("mid-conversation append");
+ try addUserText(&conv, "again");
+
+ const cfg = testConfig("gpt-4o");
+ 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 msgs = parsed.value.object.get("messages").?.array.items;
+ // Two system messages first (in derivation order), then the two users.
+ try testing.expectEqual(@as(usize, 4), msgs.len);
+ try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string);
+ try testing.expectEqualStrings("seed", msgs[0].object.get("content").?.string);
+ try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string);
+ try testing.expectEqualStrings("mid-conversation append", msgs[1].object.get("content").?.string);
+ try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string);
+ try testing.expectEqualStrings("Hello!", msgs[2].object.get("content").?.string);
+ try testing.expectEqualStrings("user", msgs[3].object.get("role").?.string);
+ try testing.expectEqualStrings("again", msgs[3].object.get("content").?.string);
+}
+
+test "serializeRequest - replace-mode system block wipes prior system messages" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("original");
+ try conv.replaceSystemMessage("fresh seed");
+ try conv.addSystemMessage("fresh append");
+ try addUserText(&conv, "Hi");
+
+ const cfg = testConfig("gpt-4o");
+ 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 msgs = parsed.value.object.get("messages").?.array.items;
+ try testing.expectEqual(@as(usize, 3), msgs.len);
+ try testing.expectEqualStrings("system", msgs[0].object.get("role").?.string);
+ try testing.expectEqualStrings("fresh seed", msgs[0].object.get("content").?.string);
+ try testing.expectEqualStrings("system", msgs[1].object.get("role").?.string);
+ try testing.expectEqualStrings("fresh append", msgs[1].object.get("content").?.string);
+ try testing.expectEqualStrings("user", msgs[2].object.get("role").?.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 = .{ .text = try conversation.textualBlockFromSlice(allocator, "thinking step") } },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer here") },
+ }, null);
+
+ const cfg = testConfig("gpt-4o");
+ 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 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 addUserText(&conv, "Hi");
+
+ var cfg = testConfig("gpt-4o");
+ 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();
+
+ 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 addUserText(&conv, "Hi");
+
+ var cfg = testConfig("gpt-4o");
+ cfg.reasoning = .off;
+
+ 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();
+
+ 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.?);
+}
+
+// -----------------------------------------------------------------------------
+// Phase 3: tools serialization, tool_calls parsing
+// -----------------------------------------------------------------------------
+
+const tool_mod = @import("tool.zig");
+
+/// Minimal in-test tool: borrows its name/description/schema slices from
+/// the test's stack. The vtable's deinit is a no-op since nothing is owned.
+const StaticToolVT = 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_,
+ };
+};
+var static_tool_ctx_sentinel: u8 = 0;
+fn makeStaticTool(
+ name: []const u8,
+ description: []const u8,
+ schema: []const u8,
+) tool_mod.Tool {
+ return .{
+ .decl = .{
+ .name = name,
+ .description = description,
+ .schema_json = schema,
+ },
+ .ctx = &static_tool_ctx_sentinel,
+ .vtable = &StaticToolVT.v,
+ };
+}
+
+test "serializeRequest - emits tools array when registry non-empty" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "call something");
+
+ var tools = emptyTools();
+ defer tools.deinit();
+
+ try tools.register(makeStaticTool("echo", "Echo a message back.",
+ \\{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}
+ ));
+
+ const cfg = testConfig("gpt-4o");
+ 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 arr = parsed.value.object.get("tools").?.array.items;
+ try testing.expectEqual(@as(usize, 1), arr.len);
+ try testing.expectEqualStrings("function", arr[0].object.get("type").?.string);
+
+ const f = arr[0].object.get("function").?.object;
+ try testing.expectEqualStrings("echo", f.get("name").?.string);
+ try testing.expectEqualStrings("Echo a message back.", f.get("description").?.string);
+
+ const params = f.get("parameters").?.object;
+ try testing.expectEqualStrings("object", params.get("type").?.string);
+ try testing.expect(params.get("properties").? == .object);
+ try testing.expect(params.get("required").? == .array);
+}
+
+test "serializeRequest - dotted tool name is wire-encoded with __" {
+ 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(makeStaticTool("std.read", "Read a file.", "{\"type\":\"object\"}"));
+
+ const cfg = testConfig("gpt-4o");
+ 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 f = parsed.value.object.get("tools").?.array.items[0].object.get("function").?.object;
+ // Internal `std.read` crosses the wire as `std__read` (OpenAI forbids dots).
+ try testing.expectEqualStrings("std__read", f.get("name").?.string);
+}
+
+test "serializeRequest - assistant ToolUse becomes tool_calls" {
+ 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, "{\"message\":\"hi\"}");
+
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling tool") },
+ .{ .ToolUse = .{ .id = id, .name = name, .input = args } },
+ }, null);
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-4o");
+ 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 msg = parsed.value.object.get("messages").?.array.items[0].object;
+ try testing.expectEqualStrings("assistant", msg.get("role").?.string);
+ try testing.expectEqualStrings("calling tool", msg.get("content").?.string);
+
+ const tcs = msg.get("tool_calls").?.array.items;
+ try testing.expectEqual(@as(usize, 1), tcs.len);
+ try testing.expectEqualStrings("call_1", tcs[0].object.get("id").?.string);
+ try testing.expectEqualStrings("function", tcs[0].object.get("type").?.string);
+
+ const fn_obj = tcs[0].object.get("function").?.object;
+ try testing.expectEqualStrings("echo", fn_obj.get("name").?.string);
+ // `arguments` is a string (JSON-as-string) per the OpenAI wire format.
+ try testing.expectEqualStrings("{\"message\":\"hi\"}", fn_obj.get("arguments").?.string);
+}
+
+test "serializeRequest - user ToolResult fans out into tool messages" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id1 = try allocator.dupe(u8, "call_a");
+ var p1: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try p1.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "42") });
+
+ const id2 = try allocator.dupe(u8, "call_b");
+ var p2: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try p2.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "oops") });
+
+ var content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = id1, .parts = p1 } });
+ try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = id2, .parts = p2 } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = content });
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-4o");
+ 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 msgs = parsed.value.object.get("messages").?.array.items;
+ try testing.expectEqual(@as(usize, 2), msgs.len);
+
+ try testing.expectEqualStrings("tool", msgs[0].object.get("role").?.string);
+ try testing.expectEqualStrings("call_a", msgs[0].object.get("tool_call_id").?.string);
+ try testing.expectEqualStrings("42", msgs[0].object.get("content").?.string);
+
+ try testing.expectEqualStrings("tool", msgs[1].object.get("role").?.string);
+ try testing.expectEqualStrings("call_b", msgs[1].object.get("tool_call_id").?.string);
+ try testing.expectEqualStrings("oops", msgs[1].object.get("content").?.string);
+}
+
+test "serializeRequest - tool result with image splits into tool + synthetic user" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "call_img");
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "the file:") });
+ try parts.append(allocator, .{ .media = .{
+ .media_type = try allocator.dupe(u8, "image/png"),
+ .data = try conversation.textualBlockFromSlice(allocator, "iVBOR=="),
+ } });
+ var content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = content });
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-4o");
+ 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 msgs = parsed.value.object.get("messages").?.array.items;
+ try testing.expectEqual(@as(usize, 2), msgs.len);
+
+ // First: the tool message (text only, no image).
+ try testing.expectEqualStrings("tool", msgs[0].object.get("role").?.string);
+ try testing.expectEqualStrings("call_img", msgs[0].object.get("tool_call_id").?.string);
+ try testing.expect(std.mem.indexOf(u8, msgs[0].object.get("content").?.string, "the file:") != null);
+
+ // Second: synthetic user message carrying the image as a data URL.
+ try testing.expectEqualStrings("user", msgs[1].object.get("role").?.string);
+ const uc = msgs[1].object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 1), uc.len);
+ try testing.expectEqualStrings("image_url", uc[0].object.get("type").?.string);
+ const url = uc[0].object.get("image_url").?.object.get("url").?.string;
+ try testing.expectEqualStrings("data:image/png;base64,iVBOR==", url);
+}
+
+test "parseStreamEvent - tool_calls delta with id and partial arguments" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xyz","type":"function","function":{"name":"echo","arguments":"{\"x\":"}}]}}]}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqual(@as(usize, 1), pd.delta.tool_calls.len);
+ const tc = pd.delta.tool_calls[0];
+ try testing.expectEqual(@as(usize, 0), tc.index);
+ try testing.expectEqualStrings("call_xyz", tc.id.?);
+ try testing.expectEqualStrings("echo", tc.name.?);
+ try testing.expectEqualStrings("{\"x\":", tc.arguments.?);
+}
+
+test "parseStreamEvent - tool_calls delta with only arguments fragment" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"1}"}}]}}]}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqual(@as(usize, 1), pd.delta.tool_calls.len);
+ const tc = pd.delta.tool_calls[0];
+ try testing.expectEqual(@as(usize, 0), tc.index);
+ try testing.expect(tc.id == null);
+ try testing.expect(tc.name == null);
+ try testing.expectEqualStrings("1}", tc.arguments.?);
+}
+
+test "parseStreamEvent - finish_reason tool_calls" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqualStrings("tool_calls", pd.delta.finish_reason.?);
+}
+
+test "parseStreamEvent - top-level error event with type and message" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"error":{"message":"Rate limit exceeded","type":"rate_limit_error","code":429}}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqualStrings("Rate limit exceeded", pd.delta.error_message.?);
+ try testing.expectEqualStrings("rate_limit_error", pd.delta.error_type.?);
+}
+
+test "parseStreamEvent - bare-string error" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"error":"something went wrong"}
+ ;
+ var pd = try parseStreamEvent(allocator, payload);
+ defer pd.deinit();
+
+ try testing.expectEqualStrings("something went wrong", pd.delta.error_message.?);
+ try testing.expect(pd.delta.error_type == null);
+}
+
+test "serializeRequest - compaction summary trims superseded prefix" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ // System prompt survives compaction.
+ try conv.addSystemMessage("you are helpful");
+ // Old prefix that must be dropped.
+ try addUserText(&conv, "ancient question");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "ancient answer") },
+ }, null);
+ // Compaction summary resets conversation context.
+ try conv.addCompactionSummary("summary of the ancient exchange");
+ // Kept-verbatim suffix replayed after the summary.
+ try addUserText(&conv, "recent question");
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-4o");
+ 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 msgs = parsed.value.object.get("messages").?.array.items;
+ // system + compaction summary (user) + recent question (user) = 3.
+ try testing.expectEqual(@as(usize, 3), 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("summary of the ancient exchange", msgs[1].object.get("content").?.string);
+ try testing.expectEqualStrings("user", msgs[2].object.get("role").?.string);
+ try testing.expectEqualStrings("recent question", msgs[2].object.get("content").?.string);
+}