diff options
| author | T <t@tjp.lol> | 2026-05-25 22:54:43 -0600 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-26 10:10:42 -0600 |
| commit | c2f727e188c1558bcc6b34569af2feab3b0366c0 (patch) | |
| tree | 24573b32e92cb45f998c24c304d2b060c6737b1f /libpanto/src/openai_chat_json.zig | |
| parent | f026bb81ae68f516910d0eb4f23c9344dd36b62b (diff) | |
phase 3 part 1
Diffstat (limited to 'libpanto/src/openai_chat_json.zig')
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 484 |
1 files changed, 467 insertions, 17 deletions
diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig index 465d951..7ca9546 100644 --- a/libpanto/src/openai_chat_json.zig +++ b/libpanto/src/openai_chat_json.zig @@ -10,6 +10,7 @@ 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". @@ -21,6 +22,26 @@ pub const StreamDelta = struct { 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, +}; + +/// 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. @@ -30,6 +51,7 @@ 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(); @@ -56,6 +78,29 @@ pub fn serializeRequest( }, } + if (tools.count() > 0) { + try s.objectField("tools"); + try s.beginArray(); + var it = tools.iterator(); + 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"); + try s.write(t.name); + try s.objectField("description"); + try s.write(t.description); + try s.objectField("parameters"); + // Emit the tool's JSON Schema verbatim. + try writeRawJson(&s, t.schema_json); + try s.endObject(); + try s.endObject(); + } + try s.endArray(); + } + try s.objectField("messages"); try s.beginArray(); for (conv.messages.items) |msg| { @@ -68,24 +113,130 @@ pub fn serializeRequest( 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 { - try s.beginObject(); + // 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) { + for (msg.content.items) |block| { + if (block != .ToolResult) continue; + const tr = block.ToolResult; + 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"); + try s.write(tr.content.items); + 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)); - // All roles flatten to a plain `content` string in outbound requests. - // Thinking blocks are intentionally dropped from history: the openai_chat - // dialect has no portable way to round-trip them (OpenAI/DeepSeek strip; - // OpenRouter/NanoGPT want a `reasoning` field instead of an inline block; - // none accept the `{"type":"thinking",...}` shape). Preserving reasoning - // across turns will land with tool-use in a later phase. + // 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"); + try s.write(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(); } @@ -97,7 +248,7 @@ fn concatTextBlocks( for (blocks) |block| { switch (block) { .Text => |tb| try out.appendSlice(allocator, tb.items), - // Thinking: dropped. ToolUse/ToolResult: phase 3+. + // Thinking, ToolUse, ToolResult: handled elsewhere or dropped. else => {}, } } @@ -111,8 +262,13 @@ fn concatTextBlocks( 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(); } }; @@ -124,19 +280,44 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedDelta var delta: StreamDelta = .{}; const root = parsed.value; - if (root != .object) return .{ .parsed = parsed, .delta = delta }; + 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. + 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 }; + 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 }; + return .{ .parsed = parsed, .delta = delta, .allocator = allocator }; } const choice = choices_v.array.items[0]; - if (choice != .object) return .{ .parsed = parsed, .delta = delta }; + 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| { @@ -152,10 +333,46 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedDelta } 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 }; + return .{ + .parsed = parsed, + .delta = delta, + .tool_calls_buf = tool_calls_buf, + .allocator = allocator, + }; } // ----------------------------------------------------------------------------- @@ -172,6 +389,11 @@ fn testConfig(model: []const u8) config_mod.OpenAIChatConfig { }; } +/// Caller deinits. +fn emptyTools() tool_registry_mod.ToolRegistry { + return tool_registry_mod.ToolRegistry.init(testing.allocator); +} + test "serializeRequest - system + user" { const allocator = testing.allocator; @@ -182,7 +404,9 @@ test "serializeRequest - system + user" { try conv.addUserMessage("Hello!"); const cfg = testConfig("gpt-4o"); - const body = try serializeRequest(allocator, &cfg, &conv); + 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, .{}); @@ -193,6 +417,8 @@ test "serializeRequest - system + user" { 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); @@ -214,7 +440,9 @@ test "serializeRequest - assistant Thinking blocks are stripped from outbound hi }); const cfg = testConfig("gpt-4o"); - const body = try serializeRequest(allocator, &cfg, &conv); + 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, .{}); @@ -237,7 +465,9 @@ test "serializeRequest - reasoning effort level included when set" { var cfg = testConfig("gpt-4o"); cfg.reasoning = .high; - const body = try serializeRequest(allocator, &cfg, &conv); + 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, .{}); @@ -259,7 +489,9 @@ test "serializeRequest - reasoning .off sends \"none\"" { var cfg = testConfig("gpt-4o"); cfg.reasoning = .off; - const body = try serializeRequest(allocator, &cfg, &conv); + 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, .{}); @@ -321,3 +553,221 @@ test "parseStreamEvent - reasoning_content" { 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![]u8 { + 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 .{ + .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 - 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 } }, + }); + + 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 c1: conversation.TextualBlock = .empty; + try c1.appendSlice(allocator, "42"); + + const id2 = try allocator.dupe(u8, "call_b"); + var c2: conversation.TextualBlock = .empty; + try c2.appendSlice(allocator, "oops"); + + var content: std.ArrayList(conversation.ContentBlock) = .empty; + try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = id1, .content = c1 } }); + try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = id2, .content = c2 } }); + 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 "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); +} |
