summaryrefslogtreecommitdiff
path: root/libpanto/src/openai_chat_json.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/openai_chat_json.zig')
-rw-r--r--libpanto/src/openai_chat_json.zig1067
1 files changed, 0 insertions, 1067 deletions
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);
-}