diff options
Diffstat (limited to 'src/anthropic_messages_json.zig')
| -rw-r--r-- | src/anthropic_messages_json.zig | 1619 |
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()); +} |
