diff options
| author | t <t@tjp.lol> | 2026-07-07 11:26:32 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-07 11:26:45 -0600 |
| commit | f83578fdc9264019a1a1cef8c5484a161167d3dd (patch) | |
| tree | 888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /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.zig | 1067 |
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); +} |
