//! 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"); /// 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 a pre-encoded JSON value into the current stringifier position. /// /// `std.json.Stringify.write` can only emit Zig values; we need to splice /// arbitrary user-supplied JSON (tool schemas, parsed tool inputs) into /// the output. We do that by parsing the bytes into `std.json.Value` and /// asking the stringifier to write them. If parsing fails, fall back to /// emitting an empty object so we never produce invalid JSON on the wire. fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void { // Use the stringifier's own allocator path via a scratch arena. 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); } /// 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 "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"); 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 conv.addUserMessage("Hello!"); try conv.addSystemMessage("mid-conversation append"); try conv.addUserMessage("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 conv.addUserMessage("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 conv.addUserMessage("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 conv.addUserMessage("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.ResultPart { 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 conv.addUserMessage("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 conv.addUserMessage("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 conv.addUserMessage("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 conv.addUserMessage("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); }