From 621d7fee0ace729f8d44126032d2c6e13f72ee7f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 7 Jul 2026 11:29:23 -0600 Subject: Move libpanto projects to libpantograph dependency Remove the in-repo libpanto sources and binding projects from pantograph. Consume libpantograph through the Zig package URL at code.tjp.lol/libpantograph.git, including the Lua module artifact used by CLI extensions. --- libpanto/src/openai_chat_json.zig | 1067 ------------------------------------- 1 file changed, 1067 deletions(-) delete mode 100644 libpanto/src/openai_chat_json.zig (limited to 'libpanto/src/openai_chat_json.zig') diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig deleted file mode 100644 index f00c97f..0000000 --- a/libpanto/src/openai_chat_json.zig +++ /dev/null @@ -1,1067 +0,0 @@ -//! 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); -} -- cgit v1.3