summaryrefslogtreecommitdiff
path: root/src/anthropic_messages_json.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:26:32 -0600
committert <t@tjp.lol>2026-07-07 11:26:45 -0600
commitf83578fdc9264019a1a1cef8c5484a161167d3dd (patch)
tree888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/anthropic_messages_json.zig
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/anthropic_messages_json.zig')
-rw-r--r--src/anthropic_messages_json.zig1619
1 files changed, 1619 insertions, 0 deletions
diff --git a/src/anthropic_messages_json.zig b/src/anthropic_messages_json.zig
new file mode 100644
index 0000000..5085149
--- /dev/null
+++ b/src/anthropic_messages_json.zig
@@ -0,0 +1,1619 @@
+//! Anthropic Messages API JSON serialization and event parsing.
+//!
+//! Two responsibilities:
+//! 1. Serialize a `Conversation` into a `/v1/messages` request body.
+//! Anthropic differs from OpenAI in several ways — see `serializeRequest`.
+//! 2. Parse one streaming SSE event payload (the JSON object after `data: `)
+//! into a strongly-typed `StreamEvent` that the provider can consume.
+//!
+//! Wire format reference:
+//! https://platform.claude.com/docs/en/build-with-claude/streaming
+//! https://platform.claude.com/docs/en/build-with-claude/extended-thinking
+
+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;
+
+// -----------------------------------------------------------------------------
+// Request serialization
+// -----------------------------------------------------------------------------
+
+/// Serialize a Conversation into a `/v1/messages` request body.
+///
+/// Differences from OpenAI Chat Completions:
+/// - System messages are extracted and concatenated into a top-level
+/// `system` string. They do not appear in the `messages` array.
+/// - `content` is always an array of typed blocks, never a bare string.
+/// - `max_tokens` is required.
+///
+/// Caller owns the returned slice.
+pub fn serializeRequest(
+ allocator: Allocator,
+ cfg: *const config_mod.AnthropicMessagesConfig,
+ 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("max_tokens");
+ try s.write(cfg.max_tokens);
+
+ try s.objectField("stream");
+ try s.write(true);
+
+ // Extended thinking configuration.
+ // `.disabled` — omit the field entirely.
+ // `.enabled` — manual budget: { type, budget_tokens, display }.
+ // `.adaptive` — adaptive mode: { type, display } + output_config.effort.
+ switch (cfg.thinking) {
+ .disabled => {},
+ .enabled => {
+ // Resolve budget: explicit value or fall back to max_tokens - 1.
+ // Clamp so budget is always strictly less than max_tokens (required
+ // by Anthropic when not using interleaved thinking).
+ const raw_budget: u32 = cfg.thinking_budget_tokens orelse (cfg.max_tokens -| 1);
+ const budget: u32 = if (raw_budget >= cfg.max_tokens) cfg.max_tokens -| 1 else raw_budget;
+ try s.objectField("thinking");
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("enabled");
+ try s.objectField("budget_tokens");
+ try s.write(budget);
+ try s.objectField("display");
+ try s.write("summarized");
+ try s.endObject();
+ },
+ .adaptive => {
+ try s.objectField("thinking");
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("adaptive");
+ try s.objectField("display");
+ try s.write("summarized");
+ try s.endObject();
+ try s.objectField("output_config");
+ try s.beginObject();
+ try s.objectField("effort");
+ try s.write(@tagName(cfg.effort));
+ try s.endObject();
+ },
+ }
+
+ // Build and emit the concatenated system prompt, if any.
+ var system_buf: std.ArrayList(u8) = .empty;
+ defer system_buf.deinit(allocator);
+ try collectSystemPrompt(conv, &system_buf, allocator);
+ if (system_buf.items.len > 0) {
+ try s.objectField("system");
+ try s.write(system_buf.items);
+ }
+
+ 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("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("input_schema");
+ try writeRawJson(&s, t.decl.schema_json);
+ try s.endObject();
+ }
+ try s.endArray();
+ }
+
+ // Emit messages (everything that isn't .system). If the conversation
+ // has been compacted, only the latest compaction summary and the
+ // messages after it are active; the superseded prefix is dropped.
+ //
+ // Prompt caching: we replicate Anthropic's "automatic caching" by
+ // placing one `cache_control` breakpoint on the last cacheable block of
+ // the request. Anthropic's native API offers a single top-level
+ // `cache_control` field for this, but Anthropic-style proxies
+ // (Bedrock, Vertex, OpenRouter) reject that field outright (400). A
+ // per-block breakpoint produces the same caching behavior and is the
+ // broadly-supported form, so we mark the block directly. As the
+ // append-only conversation grows, this breakpoint advances each request
+ // and stays well inside the 20-block lookback window, so every turn
+ // reads the prior turn's write and writes only its own new suffix.
+ const window = conversation.activeMessageWindow(conv.messages.items);
+ const cache_at: ?CacheMark = if (cfg.prompt_cache) cacheableTailBlock(window) else null;
+ try s.objectField("messages");
+ try s.beginArray();
+ for (window, 0..) |msg, mi| {
+ if (msg.role == .system) continue;
+ const mark: ?usize = if (cache_at) |c| (if (c.message == mi) c.block else null) else null;
+ try writeMessage(&s, msg, mark, cfg);
+ }
+ try s.endArray();
+
+ try s.endObject();
+
+ return try aw.toOwnedSlice();
+}
+
+/// Build the top-level Anthropic `system` string. Applies the shared
+/// append/replace derivation (see `conversation.effectiveSystemBlocks`),
+/// strips trailing newlines from each surviving block, and joins them with
+/// a horizontal rule (`\n\n---\n\n`). Anthropic's wire format requires a
+/// single string, so this rule is its concession to that constraint.
+fn collectSystemPrompt(
+ conv: *const conversation.Conversation,
+ out: *std.ArrayList(u8),
+ allocator: Allocator,
+) !void {
+ var blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items);
+ defer blocks.deinit(allocator);
+
+ const sep = "\n\n---\n\n";
+ var first = true;
+ for (blocks.items) |text| {
+ const trimmed = std.mem.trimEnd(u8, text, "\n");
+ if (!first) try out.appendSlice(allocator, sep);
+ try out.appendSlice(allocator, trimmed);
+ first = false;
+ }
+}
+
+/// Location of the prompt-cache breakpoint: the block to stamp with
+/// `cache_control`. Indices are into the active message window and into
+/// that message's `content` array.
+const CacheMark = struct { message: usize, block: usize };
+
+/// Returns whether a content block can carry a `cache_control` marker and
+/// is actually emitted on the wire. Thinking blocks can't be cached
+/// directly, and unsigned thinking blocks are dropped entirely
+/// (`writeBlock` returns early), so neither is a valid breakpoint target.
+/// System blocks are filtered before emission.
+fn isCacheableBlock(block: conversation.ContentBlock) bool {
+ return switch (block) {
+ .Text, .ToolUse, .ToolResult, .CompactionSummary => true,
+ .Thinking, .System => false,
+ };
+}
+
+/// Find the last cacheable, actually-emitted block in the active window:
+/// the spot to place the single cache breakpoint. Scans messages from the
+/// end, skipping system messages and any trailing non-cacheable blocks
+/// (e.g. a thinking block as the final assistant block). Returns null if
+/// nothing cacheable is present (then no breakpoint is emitted).
+fn cacheableTailBlock(window: []const conversation.Message) ?CacheMark {
+ var mi = window.len;
+ while (mi > 0) {
+ mi -= 1;
+ const msg = window[mi];
+ if (msg.role == .system) continue;
+ var bi = msg.content.items.len;
+ while (bi > 0) {
+ bi -= 1;
+ if (isCacheableBlock(msg.content.items[bi])) {
+ return .{ .message = mi, .block = bi };
+ }
+ }
+ }
+ return null;
+}
+
+fn writeMessage(
+ s: *std.json.Stringify,
+ msg: conversation.Message,
+ cache_block: ?usize,
+ cfg: *const config_mod.AnthropicMessagesConfig,
+) !void {
+ try s.beginObject();
+
+ try s.objectField("role");
+ try s.write(@tagName(msg.role));
+
+ try s.objectField("content");
+ try s.beginArray();
+ for (msg.content.items, 0..) |block, bi| {
+ const mark = if (cache_block) |cb| cb == bi else false;
+ try writeBlock(s, block, mark, cfg, msg.identity);
+ }
+ try s.endArray();
+
+ try s.endObject();
+}
+
+/// Emit a `"cache_control": {"type": "ephemeral"}` field. The caller must
+/// be positioned inside an open block object, after its other fields.
+fn writeCacheControl(s: *std.json.Stringify) !void {
+ try s.objectField("cache_control");
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("ephemeral");
+ try s.endObject();
+}
+
+fn writeBlock(
+ s: *std.json.Stringify,
+ block: conversation.ContentBlock,
+ mark_cache: bool,
+ cfg: *const config_mod.AnthropicMessagesConfig,
+ msg_identity: ?config_mod.WireIdentity,
+) !void {
+ switch (block) {
+ .Text => |tb| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("text");
+ try s.objectField("text");
+ try s.write(tb.items);
+ if (mark_cache) try writeCacheControl(s);
+ try s.endObject();
+ },
+ .Thinking => |tb| {
+ // Anthropic requires the signature field to round-trip a thinking
+ // block. If we don't have one (e.g. block was synthesized from a
+ // non-Anthropic provider), or if the signature came from a
+ // different provider/model than the current request, skip the
+ // block: Anthropic will reject unsigned thinking on incoming
+ // messages, and opaque signatures are not portable across
+ // provider/model boundaries.
+ const sig = tb.signature orelse return;
+ if (!conversation.thinkingSignatureMatches(tb, msg_identity, .anthropic_messages, cfg.base_url, cfg.model)) return;
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("thinking");
+ try s.objectField("thinking");
+ try s.write(tb.text.items);
+ try s.objectField("signature");
+ try s.write(sig);
+ try s.endObject();
+ },
+ .ToolUse => |tu| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("tool_use");
+ try s.objectField("id");
+ try s.write(tu.id);
+ try s.objectField("name");
+ // Replayed assistant tool_use: encode the stored dotted name.
+ 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("input");
+ // Anthropic expects `input` as a nested JSON object, not a
+ // string. The block's input bytes were assembled from
+ // `input_json_delta` fragments — splice them in verbatim.
+ // Treat empty as an empty object.
+ const input_bytes = tu.input.items;
+ if (input_bytes.len == 0) {
+ try s.beginObject();
+ try s.endObject();
+ } else {
+ try writeRawJson(s, input_bytes);
+ }
+ if (mark_cache) try writeCacheControl(s);
+ try s.endObject();
+ },
+ .ToolResult => |tr| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("tool_result");
+ try s.objectField("tool_use_id");
+ try s.write(tr.tool_use_id);
+ // Anthropic supports an `is_error` marker on tool results;
+ // emit it only when set (omitting it defaults to a success
+ // result on the wire).
+ if (tr.is_error) {
+ try s.objectField("is_error");
+ try s.write(true);
+ }
+ // Anthropic accepts `content` as an array of typed blocks:
+ // text, image (base64 source), and document (PDF).
+ try s.objectField("content");
+ try s.beginArray();
+ for (tr.parts.items) |part| {
+ switch (part) {
+ .text => |tb| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("text");
+ try s.objectField("text");
+ try s.write(tb.items);
+ try s.endObject();
+ },
+ .media => |m| {
+ const is_pdf = std.mem.eql(u8, m.media_type, "application/pdf");
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write(if (is_pdf) "document" else "image");
+ try s.objectField("source");
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("base64");
+ try s.objectField("media_type");
+ try s.write(m.media_type);
+ try s.objectField("data");
+ try s.write(m.data.items);
+ try s.endObject();
+ try s.endObject();
+ },
+ }
+ }
+ try s.endArray();
+ if (mark_cache) try writeCacheControl(s);
+ try s.endObject();
+ },
+ // System blocks never reach here: `serializeRequest` filters out
+ // `.system` messages before emitting the `messages` array, and the
+ // system text is hoisted into the top-level `system` string by
+ // `collectSystemPrompt`. Present only to keep the switch exhaustive.
+ .System => {},
+ // A compaction summary is the synthetic seed text standing in for
+ // a compacted prefix; the model reads it as ordinary user text.
+ .CompactionSummary => |cs| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("text");
+ try s.objectField("text");
+ try s.write(cs.text.items);
+ if (mark_cache) try writeCacheControl(s);
+ try s.endObject();
+ },
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Streaming event parsing
+// -----------------------------------------------------------------------------
+
+/// Parsed shape of one SSE event payload. The `tag` field selects which of
+/// the payload variants is populated.
+///
+/// String slices are borrowed from the underlying `std.json.Parsed`. The
+/// caller must keep `parsed` alive (or copy the slices) until the event is
+/// fully consumed.
+pub const StreamEventTag = enum {
+ message_start,
+ content_block_start,
+ content_block_delta,
+ content_block_stop,
+ message_delta,
+ message_stop,
+ ping,
+ @"error",
+ unknown,
+};
+
+pub const ContentBlockKind = enum { text, thinking, tool_use, unknown };
+
+/// Partial token-count snapshot emitted in `message_start` and
+/// `message_delta`. Field semantics follow Anthropic's wire format:
+///
+/// - `input_tokens`: prompt tokens billed at the base rate.
+/// - `cache_creation_input_tokens`: prompt tokens written to a new
+/// cache entry (1.25× base).
+/// - `cache_read_input_tokens`: prompt tokens served from cache
+/// (0.1× base).
+/// - `output_tokens`: response tokens billed at the output rate.
+///
+/// `message_start.usage` carries the initial input-side counts (output
+/// will be 0 or absent); `message_delta.usage` carries the final
+/// `output_tokens` and may repeat the input-side counts. The provider
+/// merges them — "missing" means "unchanged," not "reset to zero."
+pub const StreamUsage = struct {
+ input_tokens: ?u64 = null,
+ output_tokens: ?u64 = null,
+ cache_creation_input_tokens: ?u64 = null,
+ cache_read_input_tokens: ?u64 = null,
+};
+
+pub const StreamEvent = union(StreamEventTag) {
+ message_start: struct {
+ usage: StreamUsage = .{},
+ },
+ content_block_start: struct {
+ index: usize,
+ kind: ContentBlockKind,
+ // For tool_use blocks (phase 3+):
+ tool_id: ?[]const u8 = null,
+ tool_name: ?[]const u8 = null,
+ },
+ content_block_delta: struct {
+ index: usize,
+ text_delta: ?[]const u8 = null,
+ thinking_delta: ?[]const u8 = null,
+ signature_delta: ?[]const u8 = null,
+ input_json_delta: ?[]const u8 = null,
+ },
+ content_block_stop: struct {
+ index: usize,
+ },
+ message_delta: struct {
+ stop_reason: ?[]const u8 = null,
+ usage: StreamUsage = .{},
+ },
+ message_stop: void,
+ ping: void,
+ @"error": struct {
+ kind: ?[]const u8 = null,
+ message: ?[]const u8 = null,
+ },
+ unknown: void,
+};
+
+pub const ParsedStreamEvent = struct {
+ parsed: std.json.Parsed(std.json.Value),
+ event: StreamEvent,
+
+ pub fn deinit(self: *ParsedStreamEvent) void {
+ self.parsed.deinit();
+ }
+};
+
+pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedStreamEvent {
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{});
+ errdefer parsed.deinit();
+
+ const root = parsed.value;
+ if (root != .object) return .{ .parsed = parsed, .event = .unknown };
+
+ const type_v = root.object.get("type") orelse return .{ .parsed = parsed, .event = .unknown };
+ if (type_v != .string) return .{ .parsed = parsed, .event = .unknown };
+
+ const ty = type_v.string;
+ if (std.mem.eql(u8, ty, "message_start")) {
+ var usage: StreamUsage = .{};
+ if (root.object.get("message")) |m| {
+ if (m == .object) {
+ if (m.object.get("usage")) |u| {
+ if (u == .object) usage = parseStreamUsage(u.object);
+ }
+ }
+ }
+ return .{ .parsed = parsed, .event = .{ .message_start = .{ .usage = usage } } };
+ }
+
+ if (std.mem.eql(u8, ty, "content_block_start")) {
+ const idx = readIndex(root) orelse 0;
+ var kind: ContentBlockKind = .unknown;
+ var tool_id: ?[]const u8 = null;
+ var tool_name: ?[]const u8 = null;
+ if (root.object.get("content_block")) |cb| {
+ if (cb == .object) {
+ if (cb.object.get("type")) |t| {
+ if (t == .string) {
+ if (std.mem.eql(u8, t.string, "text")) {
+ kind = .text;
+ } else if (std.mem.eql(u8, t.string, "thinking")) {
+ kind = .thinking;
+ } else if (std.mem.eql(u8, t.string, "tool_use")) {
+ kind = .tool_use;
+ }
+ }
+ }
+ if (cb.object.get("id")) |i| {
+ if (i == .string) tool_id = i.string;
+ }
+ if (cb.object.get("name")) |n| {
+ if (n == .string) tool_name = n.string;
+ }
+ }
+ }
+ return .{ .parsed = parsed, .event = .{ .content_block_start = .{
+ .index = idx,
+ .kind = kind,
+ .tool_id = tool_id,
+ .tool_name = tool_name,
+ } } };
+ }
+
+ if (std.mem.eql(u8, ty, "content_block_delta")) {
+ const idx = readIndex(root) orelse 0;
+ var text_delta: ?[]const u8 = null;
+ var thinking_delta: ?[]const u8 = null;
+ var signature_delta: ?[]const u8 = null;
+ var input_json_delta: ?[]const u8 = null;
+ if (root.object.get("delta")) |d| {
+ if (d == .object) {
+ const dt = blk: {
+ if (d.object.get("type")) |t| {
+ if (t == .string) break :blk t.string;
+ }
+ break :blk "";
+ };
+ if (std.mem.eql(u8, dt, "text_delta")) {
+ if (d.object.get("text")) |v| if (v == .string) {
+ text_delta = v.string;
+ };
+ } else if (std.mem.eql(u8, dt, "thinking_delta")) {
+ if (d.object.get("thinking")) |v| if (v == .string) {
+ thinking_delta = v.string;
+ };
+ } else if (std.mem.eql(u8, dt, "signature_delta")) {
+ if (d.object.get("signature")) |v| if (v == .string) {
+ signature_delta = v.string;
+ };
+ } else if (std.mem.eql(u8, dt, "input_json_delta")) {
+ if (d.object.get("partial_json")) |v| if (v == .string) {
+ input_json_delta = v.string;
+ };
+ }
+ }
+ }
+ return .{ .parsed = parsed, .event = .{ .content_block_delta = .{
+ .index = idx,
+ .text_delta = text_delta,
+ .thinking_delta = thinking_delta,
+ .signature_delta = signature_delta,
+ .input_json_delta = input_json_delta,
+ } } };
+ }
+
+ if (std.mem.eql(u8, ty, "content_block_stop")) {
+ const idx = readIndex(root) orelse 0;
+ return .{ .parsed = parsed, .event = .{ .content_block_stop = .{ .index = idx } } };
+ }
+
+ if (std.mem.eql(u8, ty, "message_delta")) {
+ var stop_reason: ?[]const u8 = null;
+ var usage: StreamUsage = .{};
+ if (root.object.get("delta")) |d| {
+ if (d == .object) {
+ if (d.object.get("stop_reason")) |sr| {
+ if (sr == .string) stop_reason = sr.string;
+ }
+ }
+ }
+ // `usage` is on the message_delta event itself, not under `delta`.
+ if (root.object.get("usage")) |u| {
+ if (u == .object) usage = parseStreamUsage(u.object);
+ }
+ return .{ .parsed = parsed, .event = .{ .message_delta = .{ .stop_reason = stop_reason, .usage = usage } } };
+ }
+
+ if (std.mem.eql(u8, ty, "message_stop")) {
+ return .{ .parsed = parsed, .event = .message_stop };
+ }
+
+ if (std.mem.eql(u8, ty, "ping")) {
+ return .{ .parsed = parsed, .event = .ping };
+ }
+
+ if (std.mem.eql(u8, ty, "error")) {
+ var kind: ?[]const u8 = null;
+ var message: ?[]const u8 = null;
+ if (root.object.get("error")) |e| {
+ if (e == .object) {
+ if (e.object.get("type")) |t| if (t == .string) {
+ kind = t.string;
+ };
+ if (e.object.get("message")) |m| if (m == .string) {
+ message = m.string;
+ };
+ }
+ }
+ return .{ .parsed = parsed, .event = .{ .@"error" = .{ .kind = kind, .message = message } } };
+ }
+
+ return .{ .parsed = parsed, .event = .unknown };
+}
+
+fn parseStreamUsage(obj: std.json.ObjectMap) StreamUsage {
+ return .{
+ .input_tokens = readOptionalU64(obj, "input_tokens"),
+ .output_tokens = readOptionalU64(obj, "output_tokens"),
+ .cache_creation_input_tokens = readOptionalU64(obj, "cache_creation_input_tokens"),
+ .cache_read_input_tokens = readOptionalU64(obj, "cache_read_input_tokens"),
+ };
+}
+
+fn readOptionalU64(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);
+}
+
+fn readIndex(root: std.json.Value) ?usize {
+ const v = root.object.get("index") 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.AnthropicMessagesConfig {
+ return .{
+ .api_key = "k",
+ .base_url = "u",
+ .model = model,
+ .max_tokens = 1024,
+ };
+}
+
+/// 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 extracted into top-level field" {
+ 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("claude-sonnet-4-20250514");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_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("claude-sonnet-4-20250514", root.get("model").?.string);
+ try testing.expect(root.get("stream").?.bool);
+ try testing.expectEqual(@as(i64, 1024), root.get("max_tokens").?.integer);
+ try testing.expectEqualStrings("You are helpful.", root.get("system").?.string);
+
+ // Only the user message appears in `messages`.
+ const msgs = root.get("messages").?.array.items;
+ try testing.expectEqual(@as(usize, 1), msgs.len);
+ try testing.expectEqualStrings("user", msgs[0].object.get("role").?.string);
+
+ // Content is an array of typed blocks, never a bare string.
+ const content = msgs[0].object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 1), content.len);
+ try testing.expectEqualStrings("text", content[0].object.get("type").?.string);
+ try testing.expectEqualStrings("Hello!", content[0].object.get("text").?.string);
+}
+
+test "serializeRequest - prompt_cache marks last block with cache_control" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "Hi");
+
+ var cfg = testConfig("claude-x");
+ cfg.prompt_cache = true;
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ // No top-level field (would break Bedrock/Vertex/OpenRouter).
+ try testing.expect(parsed.value.object.get("cache_control") == null);
+ // The single user text block carries the breakpoint.
+ const block = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items[0].object;
+ try testing.expectEqualStrings("ephemeral", block.get("cache_control").?.object.get("type").?.string);
+}
+
+test "serializeRequest - cache breakpoint lands on the final block only" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "first");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "reply") },
+ }, null);
+ try addUserText(&conv, "second");
+
+ var cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_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);
+ // Only the last message's only block is marked.
+ try testing.expect(msgs[0].object.get("content").?.array.items[0].object.get("cache_control") == null);
+ try testing.expect(msgs[1].object.get("content").?.array.items[0].object.get("cache_control") == null);
+ try testing.expect(msgs[2].object.get("content").?.array.items[0].object.get("cache_control") != null);
+}
+
+test "serializeRequest - cache breakpoint skips a trailing thinking block" {
+ // A thinking block can't carry cache_control; the breakpoint must fall
+ // back to the prior cacheable block (here the assistant text).
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const sig = try allocator.dupe(u8, "EqQBCgIYAhIM1gbcDa9GJwZA");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ .{ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "trailing thought"),
+ .signature = sig,
+ } },
+ }, null);
+ try conversation.setThinkingOrigins(allocator, conv.messages.items[0].content.items, .anthropic_messages, "u", "claude-x");
+
+ var cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ // text block marked, thinking block not.
+ try testing.expectEqualStrings("text", content[0].object.get("type").?.string);
+ try testing.expect(content[0].object.get("cache_control") != null);
+ try testing.expectEqualStrings("thinking", content[1].object.get("type").?.string);
+ try testing.expect(content[1].object.get("cache_control") == null);
+}
+
+test "serializeRequest - thinking signature replays from message identity when block origin is absent" {
+ // A reloaded thinking block need not carry its own signature_origin: the
+ // enclosing message's identity is the per-message source of truth, so a
+ // host can rely on it alone (no per-block identity copy required).
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addAssistantMessage(&.{
+ .{
+ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "reasoned"),
+ .signature = try allocator.dupe(u8, "EqQBCgIYAhIM1gbcDa9GJwZA"),
+ // No signature_origin on the block.
+ },
+ },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ }, null);
+ // Per-message identity matching the request.
+ conv.messages.items[0].identity = try conversation.dupeWireIdentity(allocator, .{
+ .api_style = .anthropic_messages,
+ .base_url = "u",
+ .model = "claude-x",
+ });
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ try testing.expectEqualStrings("thinking", content[0].object.get("type").?.string);
+ try testing.expectEqualStrings("EqQBCgIYAhIM1gbcDa9GJwZA", content[0].object.get("signature").?.string);
+}
+
+test "serializeRequest - thinking dropped when message identity targets a different model" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "reasoned"),
+ .signature = try allocator.dupe(u8, "EqQBCgIYAhIM1gbcDa9GJwZA"),
+ } },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ }, null);
+ // Identity points at a *different* model than the request.
+ conv.messages.items[0].identity = try conversation.dupeWireIdentity(allocator, .{
+ .api_style = .anthropic_messages,
+ .base_url = "u",
+ .model = "some-other-model",
+ });
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ // Only the text block survives; the unportable thinking block is dropped.
+ try testing.expectEqual(@as(usize, 1), content.len);
+ try testing.expectEqualStrings("text", content[0].object.get("type").?.string);
+}
+
+test "serializeRequest - prompt_cache disabled omits all cache_control" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "Hi");
+
+ var cfg = testConfig("claude-x");
+ cfg.prompt_cache = false;
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ try testing.expect(parsed.value.object.get("cache_control") == null);
+ const block = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items[0].object;
+ try testing.expect(block.get("cache_control") == null);
+}
+
+test "serializeRequest - multiple system messages joined with horizontal rule" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("Be terse.");
+ try conv.addSystemMessage("Be accurate.");
+ try addUserText(&conv, "Hi");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ try testing.expectEqualStrings(
+ "Be terse.\n\n---\n\nBe accurate.",
+ parsed.value.object.get("system").?.string,
+ );
+}
+
+test "serializeRequest - replace-mode system block wipes prior system text" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("original seed");
+ try conv.addSystemMessage("original append");
+ try conv.replaceSystemMessage("fresh seed");
+ try conv.addSystemMessage("fresh append");
+ try addUserText(&conv, "Hi");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ try testing.expectEqualStrings(
+ "fresh seed\n\n---\n\nfresh append",
+ parsed.value.object.get("system").?.string,
+ );
+}
+
+test "serializeRequest - trailing newlines stripped before the rule join" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addSystemMessage("line one\n\n");
+ try conv.addSystemMessage("line two\n");
+ try addUserText(&conv, "Hi");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ try testing.expectEqualStrings(
+ "line one\n\n---\n\nline two",
+ parsed.value.object.get("system").?.string,
+ );
+}
+
+test "serializeRequest - no system messages omits the system field" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "Hi");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ try testing.expect(parsed.value.object.get("system") == null);
+}
+
+test "serializeRequest - signed assistant Thinking blocks round-trip" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const sig = try allocator.dupe(u8, "EqQBCgIYAhIM1gbcDa9GJwZA");
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "let me think"),
+ .signature = sig,
+ } },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "the answer is 42") },
+ }, null);
+ try conversation.setThinkingOrigins(allocator, conv.messages.items[0].content.items, .anthropic_messages, "u", "claude-x");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_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);
+
+ const content = msg.object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 2), content.len);
+
+ try testing.expectEqualStrings("thinking", content[0].object.get("type").?.string);
+ try testing.expectEqualStrings("let me think", content[0].object.get("thinking").?.string);
+ try testing.expectEqualStrings(
+ "EqQBCgIYAhIM1gbcDa9GJwZA",
+ content[0].object.get("signature").?.string,
+ );
+
+ try testing.expectEqualStrings("text", content[1].object.get("type").?.string);
+ try testing.expectEqualStrings("the answer is 42", content[1].object.get("text").?.string);
+}
+
+test "serializeRequest - mismatched thinking origin drops the block" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const sig = try allocator.dupe(u8, "EqQBCgIYAhIM1gbcDa9GJwZA");
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "other provider thinking"),
+ .signature = sig,
+ } },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ }, null);
+ try conversation.setThinkingOrigins(allocator, conv.messages.items[0].content.items, .openai_responses, "https://api.individual.githubcopilot.com", "gpt-5.4-mini");
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 1), content.len);
+ try testing.expectEqualStrings("text", content[0].object.get("type").?.string);
+}
+
+test "serializeRequest - unsigned Thinking blocks are dropped" {
+ // Anthropic rejects thinking blocks without a valid signature on inbound
+ // messages. If a block lacks one (e.g. from a different provider or an
+ // interrupted stream), omit it rather than send a guaranteed-400 request.
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{
+ .text = try conversation.textualBlockFromSlice(allocator, "unsigned thinking"),
+ .signature = null,
+ } },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ }, null);
+
+ const cfg = testConfig("claude-x");
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 1), content.len);
+ try testing.expectEqualStrings("text", content[0].object.get("type").?.string);
+}
+
+test "parseStreamEvent - message_start" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude","stop_reason":null}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(StreamEventTag.message_start, @as(StreamEventTag, pe.event));
+}
+
+test "parseStreamEvent - content_block_start text" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(StreamEventTag.content_block_start, @as(StreamEventTag, pe.event));
+ try testing.expectEqual(@as(usize, 0), pe.event.content_block_start.index);
+ try testing.expectEqual(ContentBlockKind.text, pe.event.content_block_start.kind);
+}
+
+test "parseStreamEvent - content_block_start thinking" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(ContentBlockKind.thinking, pe.event.content_block_start.kind);
+}
+
+test "parseStreamEvent - text_delta" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(@as(usize, 1), pe.event.content_block_delta.index);
+ try testing.expectEqualStrings("Hello", pe.event.content_block_delta.text_delta.?);
+ try testing.expect(pe.event.content_block_delta.thinking_delta == null);
+}
+
+test "parseStreamEvent - thinking_delta" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"step 1"}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqualStrings("step 1", pe.event.content_block_delta.thinking_delta.?);
+}
+
+test "parseStreamEvent - signature_delta" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"abc123"}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqualStrings("abc123", pe.event.content_block_delta.signature_delta.?);
+}
+
+test "parseStreamEvent - content_block_stop" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"content_block_stop","index":2}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(@as(usize, 2), pe.event.content_block_stop.index);
+}
+
+test "parseStreamEvent - message_delta stop_reason" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqualStrings("end_turn", pe.event.message_delta.stop_reason.?);
+}
+
+test "parseStreamEvent - message_stop" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"message_stop"}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(StreamEventTag.message_stop, @as(StreamEventTag, pe.event));
+}
+
+test "parseStreamEvent - ping" {
+ const allocator = testing.allocator;
+ const payload = "{\"type\":\"ping\"}";
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(StreamEventTag.ping, @as(StreamEventTag, pe.event));
+}
+
+test "parseStreamEvent - error" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"error","error":{"type":"overloaded_error","message":"too busy"}}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqualStrings("overloaded_error", pe.event.@"error".kind.?);
+ try testing.expectEqualStrings("too busy", pe.event.@"error".message.?);
+}
+
+test "parseStreamEvent - unknown type" {
+ const allocator = testing.allocator;
+ const payload =
+ \\{"type":"some_future_event","extra":"data"}
+ ;
+ var pe = try parseStreamEvent(allocator, payload);
+ defer pe.deinit();
+ try testing.expectEqual(StreamEventTag.unknown, @as(StreamEventTag, pe.event));
+}
+
+// -----------------------------------------------------------------------------
+// Phase 3: tools serialization, ToolUse + ToolResult content blocks
+// -----------------------------------------------------------------------------
+
+const tool_mod = @import("tool.zig");
+
+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 = tool_registry_mod.ToolRegistry.init(allocator);
+ defer tools.deinit();
+ try tools.register(makeStaticTool("echo", "Echo a message back.",
+ \\{"type":"object","properties":{"message":{"type":"string"}},"required":["message"]}
+ ));
+
+ const cfg = testConfig("claude-x");
+ 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("echo", arr[0].object.get("name").?.string);
+ try testing.expectEqualStrings("Echo a message back.", arr[0].object.get("description").?.string);
+
+ const schema = arr[0].object.get("input_schema").?.object;
+ try testing.expectEqualStrings("object", schema.get("type").?.string);
+ try testing.expect(schema.get("properties").? == .object);
+}
+
+test "serializeRequest - dotted names are wire-encoded in tools and history" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ // A prior assistant tool_use replayed from history (dotted internally).
+ const id = try allocator.dupe(u8, "tu_1");
+ const name = try allocator.dupe(u8, "std.write");
+ var input: conversation.TextualBlock = .empty;
+ try input.appendSlice(allocator, "{}");
+ try conv.addAssistantMessage(&.{
+ .{ .ToolUse = .{ .id = id, .name = name, .input = input } },
+ }, null);
+
+ var tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer tools.deinit();
+ try tools.register(makeStaticTool("std.read", "Read.", "{\"type\":\"object\"}"));
+
+ const cfg = testConfig("claude-x");
+ 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();
+
+ // Tool list: `std.read` -> `std__read`.
+ const tool_name = parsed.value.object.get("tools").?.array.items[0]
+ .object.get("name").?.string;
+ try testing.expectEqualStrings("std__read", tool_name);
+
+ // Replayed history tool_use: `std.write` -> `std__write`.
+ const hist_name = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items[0].object.get("name").?.string;
+ try testing.expectEqualStrings("std__write", hist_name);
+}
+
+test "serializeRequest - assistant ToolUse becomes tool_use content block" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "tu_1");
+ const name = try allocator.dupe(u8, "echo");
+ var input: conversation.TextualBlock = .empty;
+ try input.appendSlice(allocator, "{\"message\":\"hi\"}");
+
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling tool") },
+ .{ .ToolUse = .{ .id = id, .name = name, .input = input } },
+ }, null);
+
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const cfg = testConfig("claude-x");
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const content = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 2), content.len);
+ try testing.expectEqualStrings("text", content[0].object.get("type").?.string);
+
+ try testing.expectEqualStrings("tool_use", content[1].object.get("type").?.string);
+ try testing.expectEqualStrings("tu_1", content[1].object.get("id").?.string);
+ try testing.expectEqualStrings("echo", content[1].object.get("name").?.string);
+ // `input` is a nested JSON object, NOT a string.
+ const inp = content[1].object.get("input").?.object;
+ try testing.expectEqualStrings("hi", inp.get("message").?.string);
+}
+
+test "serializeRequest - user ToolResult becomes tool_result content block" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "tu_1");
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "the answer is 42") });
+
+ var blocks: std.ArrayList(conversation.ContentBlock) = .empty;
+ try blocks.append(allocator, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = blocks });
+
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const cfg = testConfig("claude-x");
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_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("user", msg.get("role").?.string);
+
+ const arr = msg.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 1), arr.len);
+ try testing.expectEqualStrings("tool_result", arr[0].object.get("type").?.string);
+ try testing.expectEqualStrings("tu_1", arr[0].object.get("tool_use_id").?.string);
+ // A successful result omits the is_error marker entirely.
+ try testing.expect(arr[0].object.get("is_error") == null);
+ const tr_content = arr[0].object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 1), tr_content.len);
+ try testing.expectEqualStrings("text", tr_content[0].object.get("type").?.string);
+ try testing.expectEqualStrings("the answer is 42", tr_content[0].object.get("text").?.string);
+}
+
+test "serializeRequest - error ToolResult emits is_error: true" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "tu_err");
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "file not found") });
+
+ var blocks: std.ArrayList(conversation.ContentBlock) = .empty;
+ try blocks.append(allocator, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts, .is_error = true } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = blocks });
+
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const cfg = testConfig("claude-x");
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_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;
+ const arr = msg.get("content").?.array.items;
+ try testing.expectEqualStrings("tool_result", arr[0].object.get("type").?.string);
+ try testing.expect(arr[0].object.get("is_error").?.bool);
+}
+
+test "serializeRequest - tool result with image part emits image block" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "tu_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 blocks: std.ArrayList(conversation.ContentBlock) = .empty;
+ try blocks.append(allocator, .{ .ToolResult = .{ .tool_use_id = id, .parts = parts } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = blocks });
+
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const cfg = testConfig("claude-x");
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_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;
+ const arr = msg.get("content").?.array.items[0].object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 2), arr.len);
+ try testing.expectEqualStrings("text", arr[0].object.get("type").?.string);
+ try testing.expectEqualStrings("image", arr[1].object.get("type").?.string);
+ const src = arr[1].object.get("source").?.object;
+ try testing.expectEqualStrings("base64", src.get("type").?.string);
+ try testing.expectEqualStrings("image/png", src.get("media_type").?.string);
+ try testing.expectEqualStrings("iVBOR==", src.get("data").?.string);
+}
+
+test "serializeRequest - thinking disabled omits thinking and effort fields" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var cfg = testConfig("claude-x");
+ cfg.thinking = .disabled;
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_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.expect(root.get("thinking") == null);
+ try testing.expect(root.get("effort") == null);
+}
+
+test "serializeRequest - thinking enabled explicit budget" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var cfg = testConfig("claude-x"); // max_tokens = 1024
+ cfg.thinking = .enabled;
+ cfg.thinking_budget_tokens = 500; // within max_tokens
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const root = parsed.value.object;
+ const th = root.get("thinking").?.object;
+ try testing.expectEqualStrings("enabled", th.get("type").?.string);
+ try testing.expectEqual(@as(i64, 500), th.get("budget_tokens").?.integer);
+ try testing.expectEqualStrings("summarized", th.get("display").?.string);
+ try testing.expect(root.get("effort") == null);
+}
+
+test "serializeRequest - thinking enabled null budget falls back to max_tokens - 1" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var cfg = testConfig("claude-x"); // max_tokens = 1024
+ cfg.thinking = .enabled;
+ cfg.thinking_budget_tokens = null;
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const th = parsed.value.object.get("thinking").?.object;
+ try testing.expectEqual(@as(i64, 1023), th.get("budget_tokens").?.integer);
+}
+
+test "serializeRequest - thinking enabled budget clamped when >= max_tokens" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var cfg = testConfig("claude-x"); // max_tokens = 1024
+ cfg.thinking = .enabled;
+ cfg.thinking_budget_tokens = 2_000; // > max_tokens
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const th = parsed.value.object.get("thinking").?.object;
+ // Clamped to max_tokens - 1 = 1023
+ try testing.expectEqual(@as(i64, 1023), th.get("budget_tokens").?.integer);
+}
+
+test "serializeRequest - thinking adaptive default effort" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var cfg = testConfig("claude-x");
+ cfg.thinking = .adaptive;
+ // effort defaults to .medium
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const root = parsed.value.object;
+ const th = root.get("thinking").?.object;
+ try testing.expectEqualStrings("adaptive", th.get("type").?.string);
+ try testing.expectEqualStrings("summarized", th.get("display").?.string);
+ try testing.expect(th.get("budget_tokens") == null);
+ try testing.expect(root.get("effort") == null);
+ const oc = root.get("output_config").?.object;
+ try testing.expectEqualStrings("medium", oc.get("effort").?.string);
+}
+
+test "serializeRequest - thinking adaptive explicit effort levels" {
+ const allocator = testing.allocator;
+ const efforts = [_]struct { e: config_mod.Effort, name: []const u8 }{
+ .{ .e = .low, .name = "low" },
+ .{ .e = .medium, .name = "medium" },
+ .{ .e = .high, .name = "high" },
+ .{ .e = .xhigh, .name = "xhigh" },
+ .{ .e = .max, .name = "max" },
+ };
+ for (efforts) |entry| {
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var cfg = testConfig("claude-x");
+ cfg.thinking = .adaptive;
+ cfg.effort = entry.e;
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const oc = parsed.value.object.get("output_config").?.object;
+ try testing.expectEqualStrings(entry.name, oc.get("effort").?.string);
+ }
+}
+
+test "serializeRequest - thinking adaptive ignores budget_tokens" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var cfg = testConfig("claude-x");
+ cfg.thinking = .adaptive;
+ cfg.thinking_budget_tokens = 9_999; // should be ignored
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const th = parsed.value.object.get("thinking").?.object;
+ try testing.expect(th.get("budget_tokens") == null);
+}
+
+test "serializeRequest - empty ToolUse input becomes {} not invalid JSON" {
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "tu_1");
+ const name = try allocator.dupe(u8, "noargs");
+ try conv.addAssistantMessage(&.{
+ .{ .ToolUse = .{ .id = id, .name = name, .input = .empty } },
+ }, null);
+
+ var empty_tools = tool_registry_mod.ToolRegistry.init(allocator);
+ defer empty_tools.deinit();
+ const cfg = testConfig("claude-x");
+ const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools);
+ defer allocator.free(body);
+
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const tu = parsed.value.object.get("messages").?.array.items[0]
+ .object.get("content").?.array.items[0].object;
+ try testing.expectEqualStrings("tool_use", tu.get("type").?.string);
+ try testing.expect(tu.get("input").? == .object);
+ try testing.expectEqual(@as(usize, 0), tu.get("input").?.object.count());
+}