summaryrefslogtreecommitdiff
path: root/libpanto/src/anthropic_messages_json.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-18 14:15:55 -0600
committert <t@tjp.lol>2026-06-18 14:16:24 -0600
commit270cd00129551647e7c764a13836e03e0b2dc4e2 (patch)
treef939bbf999dd67d996b4025b03dbfe4abd30b9f4 /libpanto/src/anthropic_messages_json.zig
parent7c2d8825660f73198149c0b2ce26166b16ba3532 (diff)
preserve signature origins across compactions
Diffstat (limited to 'libpanto/src/anthropic_messages_json.zig')
-rw-r--r--libpanto/src/anthropic_messages_json.zig79
1 files changed, 76 insertions, 3 deletions
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
index 1b1cf6e..5f119f3 100644
--- a/libpanto/src/anthropic_messages_json.zig
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -262,7 +262,7 @@ fn writeMessage(
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);
+ try writeBlock(s, block, mark, cfg, msg.identity);
}
try s.endArray();
@@ -284,6 +284,7 @@ fn writeBlock(
block: conversation.ContentBlock,
mark_cache: bool,
cfg: *const config_mod.AnthropicMessagesConfig,
+ msg_identity: ?config_mod.WireIdentity,
) !void {
switch (block) {
.Text => |tb| {
@@ -304,8 +305,7 @@ fn writeBlock(
// messages, and opaque signatures are not portable across
// provider/model boundaries.
const sig = tb.signature orelse return;
- const origin = tb.signature_origin orelse return;
- if (!origin.matches(.anthropic_messages, cfg.base_url, cfg.model)) 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");
@@ -819,6 +819,79 @@ test "serializeRequest - cache breakpoint skips a trailing thinking block" {
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;