summaryrefslogtreecommitdiff
path: root/libpanto/src/anthropic_messages_json.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/anthropic_messages_json.zig')
-rw-r--r--libpanto/src/anthropic_messages_json.zig638
1 files changed, 638 insertions, 0 deletions
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
new file mode 100644
index 0000000..8f44edc
--- /dev/null
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -0,0 +1,638 @@
+//! 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));
+}