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.zig1619
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());
-}