//! 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"); // ----------------------------------------------------------------------------- // 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(); } /// Splice a pre-encoded JSON value into the current stringifier position. /// On parse failure, emit `{}` so we never produce invalid wire JSON. fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const parsed = std.json.parseFromSlice( std.json.Value, arena.allocator(), raw, .{}, ) catch { try s.beginObject(); try s.endObject(); return; }; try s.write(parsed.value); } fn writeToolUseInput(s: *std.json.Stringify, raw: []const u8) !void { var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), raw, .{}) catch { try s.beginObject(); try s.objectField("_invalid_input"); try s.write(raw); try s.endObject(); return; }; if (parsed.value != .object) { try s.beginObject(); try s.objectField("_invalid_input"); try s.write(raw); try s.endObject(); return; } try s.write(parsed.value); } /// 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 writeToolUseInput(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 { role: ?[]const u8 = null, 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 role: ?[]const u8 = null; var usage: StreamUsage = .{}; if (root.object.get("message")) |m| { if (m == .object) { if (m.object.get("role")) |r| { if (r == .string) role = r.string; } if (m.object.get("usage")) |u| { if (u == .object) usage = parseStreamUsage(u.object); } } } return .{ .parsed = parsed, .event = .{ .message_start = .{ .role = role, .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)); try testing.expectEqualStrings("assistant", pe.event.message_start.role.?); } 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()); }