diff options
| author | t <t@tjp.lol> | 2026-07-07 11:29:23 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-07-07 12:15:28 -0600 |
| commit | 621d7fee0ace729f8d44126032d2c6e13f72ee7f (patch) | |
| tree | a2938b0a9e5c0930e8644721abbe94875df9ff08 /libpanto/src/anthropic_messages_json.zig | |
| parent | 0fa6d4209ff9b4a95e7d1955887aa4c73ee3423c (diff) | |
Remove the in-repo libpanto sources and binding projects from pantograph.
Consume libpantograph through the Zig package URL at
code.tjp.lol/libpantograph.git, including the Lua module artifact used by
CLI extensions.
Diffstat (limited to 'libpanto/src/anthropic_messages_json.zig')
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 1619 |
1 files changed, 0 insertions, 1619 deletions
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig deleted file mode 100644 index 5085149..0000000 --- a/libpanto/src/anthropic_messages_json.zig +++ /dev/null @@ -1,1619 +0,0 @@ -//! 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()); -} |
