//! 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"); // ----------------------------------------------------------------------------- // 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, ) ![]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); // 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); } // Emit messages (everything that isn't .system). try s.objectField("messages"); try s.beginArray(); for (conv.messages.items) |msg| { if (msg.role == .system) continue; try writeMessage(&s, msg, allocator); } try s.endArray(); try s.endObject(); return try aw.toOwnedSlice(); } fn collectSystemPrompt( conv: *const conversation.Conversation, out: *std.ArrayList(u8), allocator: Allocator, ) !void { var first = true; for (conv.messages.items) |msg| { if (msg.role != .system) continue; for (msg.content.items) |block| { if (block != .Text) continue; if (!first) try out.append(allocator, '\n'); try out.appendSlice(allocator, block.Text.items); first = false; } } } fn writeMessage( s: *std.json.Stringify, msg: conversation.Message, allocator: Allocator, ) !void { _ = allocator; try s.beginObject(); try s.objectField("role"); try s.write(@tagName(msg.role)); try s.objectField("content"); try s.beginArray(); for (msg.content.items) |block| { try writeBlock(s, block); } try s.endArray(); try s.endObject(); } fn writeBlock(s: *std.json.Stringify, block: conversation.ContentBlock) !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); 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), skip the block: Anthropic will reject // unsigned thinking on incoming messages. const sig = tb.signature orelse 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 / ToolResult: phase 3+. .ToolUse, .ToolResult => {}, } } // ----------------------------------------------------------------------------- // 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 }; pub const StreamEvent = union(StreamEventTag) { message_start: struct { role: ?[]const u8 = null, }, 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, }, message_stop: void, ping: void, @"error": struct { kind: ?[]const u8 = null, message: ?[]const u8 = null, }, unknown: void, }; pub const ParsedStreamEvent = struct { parsed: std.json.Parsed(std.json.Value), event: StreamEvent, pub fn deinit(self: *ParsedStreamEvent) void { self.parsed.deinit(); } }; pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedStreamEvent { var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{}); errdefer parsed.deinit(); const root = parsed.value; if (root != .object) return .{ .parsed = parsed, .event = .unknown }; const type_v = root.object.get("type") orelse return .{ .parsed = parsed, .event = .unknown }; if (type_v != .string) return .{ .parsed = parsed, .event = .unknown }; const ty = type_v.string; if (std.mem.eql(u8, ty, "message_start")) { var role: ?[]const u8 = null; if (root.object.get("message")) |m| { if (m == .object) { if (m.object.get("role")) |r| { if (r == .string) role = r.string; } } } return .{ .parsed = parsed, .event = .{ .message_start = .{ .role = role } } }; } 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; if (root.object.get("delta")) |d| { if (d == .object) { if (d.object.get("stop_reason")) |sr| { if (sr == .string) stop_reason = sr.string; } } } return .{ .parsed = parsed, .event = .{ .message_delta = .{ .stop_reason = stop_reason } } }; } 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 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 "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 conv.addUserMessage("Hello!"); const cfg = testConfig("claude-sonnet-4-20250514"); const body = try serializeRequest(allocator, &cfg, &conv); 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 - multiple system messages concatenated with newlines" { const allocator = testing.allocator; var conv = conversation.Conversation.init(allocator); defer conv.deinit(); try conv.addSystemMessage("Be terse."); try conv.addSystemMessage("Be accurate."); try conv.addUserMessage("Hi"); const cfg = testConfig("claude-x"); const body = try serializeRequest(allocator, &cfg, &conv); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); try testing.expectEqualStrings( "Be terse.\nBe accurate.", 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 conv.addUserMessage("Hi"); const cfg = testConfig("claude-x"); const body = try serializeRequest(allocator, &cfg, &conv); 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") }, }); const cfg = testConfig("claude-x"); const body = try serializeRequest(allocator, &cfg, &conv); 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 - 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") }, }); const cfg = testConfig("claude-x"); const body = try serializeRequest(allocator, &cfg, &conv); defer allocator.free(body); var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); defer parsed.deinit(); const content = parsed.value.object.get("messages").?.array.items[0] .object.get("content").?.array.items; try testing.expectEqual(@as(usize, 1), content.len); try testing.expectEqualStrings("text", content[0].object.get("type").?.string); } test "parseStreamEvent - message_start" { const allocator = testing.allocator; const payload = \\{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude","stop_reason":null}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(StreamEventTag.message_start, @as(StreamEventTag, pe.event)); try testing.expectEqualStrings("assistant", pe.event.message_start.role.?); } test "parseStreamEvent - content_block_start text" { const allocator = testing.allocator; const payload = \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(StreamEventTag.content_block_start, @as(StreamEventTag, pe.event)); try testing.expectEqual(@as(usize, 0), pe.event.content_block_start.index); try testing.expectEqual(ContentBlockKind.text, pe.event.content_block_start.kind); } test "parseStreamEvent - content_block_start thinking" { const allocator = testing.allocator; const payload = \\{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(ContentBlockKind.thinking, pe.event.content_block_start.kind); } test "parseStreamEvent - text_delta" { const allocator = testing.allocator; const payload = \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Hello"}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(@as(usize, 1), pe.event.content_block_delta.index); try testing.expectEqualStrings("Hello", pe.event.content_block_delta.text_delta.?); try testing.expect(pe.event.content_block_delta.thinking_delta == null); } test "parseStreamEvent - thinking_delta" { const allocator = testing.allocator; const payload = \\{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"step 1"}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqualStrings("step 1", pe.event.content_block_delta.thinking_delta.?); } test "parseStreamEvent - signature_delta" { const allocator = testing.allocator; const payload = \\{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"abc123"}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqualStrings("abc123", pe.event.content_block_delta.signature_delta.?); } test "parseStreamEvent - content_block_stop" { const allocator = testing.allocator; const payload = \\{"type":"content_block_stop","index":2} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(@as(usize, 2), pe.event.content_block_stop.index); } test "parseStreamEvent - message_delta stop_reason" { const allocator = testing.allocator; const payload = \\{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqualStrings("end_turn", pe.event.message_delta.stop_reason.?); } test "parseStreamEvent - message_stop" { const allocator = testing.allocator; const payload = \\{"type":"message_stop"} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(StreamEventTag.message_stop, @as(StreamEventTag, pe.event)); } test "parseStreamEvent - ping" { const allocator = testing.allocator; const payload = "{\"type\":\"ping\"}"; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(StreamEventTag.ping, @as(StreamEventTag, pe.event)); } test "parseStreamEvent - error" { const allocator = testing.allocator; const payload = \\{"type":"error","error":{"type":"overloaded_error","message":"too busy"}} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqualStrings("overloaded_error", pe.event.@"error".kind.?); try testing.expectEqualStrings("too busy", pe.event.@"error".message.?); } test "parseStreamEvent - unknown type" { const allocator = testing.allocator; const payload = \\{"type":"some_future_event","extra":"data"} ; var pe = try parseStreamEvent(allocator, payload); defer pe.deinit(); try testing.expectEqual(StreamEventTag.unknown, @as(StreamEventTag, pe.event)); }