diff options
| author | t <t@tjp.lol> | 2026-06-09 11:56:15 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-09 11:56:35 -0600 |
| commit | a7fe265365ba5e8bfbd0e65c827d38c038be7b0f (patch) | |
| tree | 6c7bc4a5ef2f1ba6167b498aa03d17c5b7afb609 /libpanto/src | |
| parent | 1cef19b12d59147ec32e4c0cd6a4828384b16417 (diff) | |
anthropic thinking
Diffstat (limited to 'libpanto/src')
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 192 | ||||
| -rw-r--r-- | libpanto/src/config.zig | 84 | ||||
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 4 | ||||
| -rw-r--r-- | libpanto/src/provider_anthropic_messages.zig | 73 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 2 | ||||
| -rw-r--r-- | libpanto/src/session.zig | 227 |
6 files changed, 574 insertions, 8 deletions
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index bccf692..78f4b5f 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -52,6 +52,41 @@ pub fn serializeRequest( 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 } + top-level 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("effort"); + try s.write(@tagName(cfg.effort)); + }, + } + // Build and emit the concatenated system prompt, if any. var system_buf: std.ArrayList(u8) = .empty; defer system_buf.deinit(allocator); @@ -1134,6 +1169,163 @@ test "serializeRequest - tool result with image part emits image block" { 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 conv.addUserMessage("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 conv.addUserMessage("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 conv.addUserMessage("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 conv.addUserMessage("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 conv.addUserMessage("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.expectEqualStrings("medium", root.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 conv.addUserMessage("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(); + try testing.expectEqualStrings(entry.name, parsed.value.object.get("effort").?.string); + } +} + +test "serializeRequest - thinking adaptive ignores budget_tokens" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("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; diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index 5e01bb1..587f00a 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -41,6 +41,28 @@ pub const ReasoningEffort = enum { high, }; +/// Anthropic extended-thinking mode. +pub const Thinking = enum { + /// Do not request extended thinking. + disabled, + /// Manual extended thinking with an explicit token budget (`thinking_budget_tokens`). + /// Supported on all current models including Haiku 4.5. Default. + enabled, + /// Adaptive thinking: Claude decides when and how much to think. + /// Requires Opus 4.6 / Sonnet 4.6 or newer; automatic on Opus 4.7+. + adaptive, +}; + +/// Effort level sent with Anthropic adaptive thinking (`thinking = .adaptive`). +/// Ignored when `thinking` is `.enabled` or `.disabled`. +pub const Effort = enum { + low, + medium, + high, + xhigh, + max, +}; + pub const OpenAIChatConfig = struct { api_key: []const u8, base_url: []const u8, @@ -57,6 +79,22 @@ pub const AnthropicMessagesConfig = struct { api_version: []const u8 = "2023-06-01", /// Required by Anthropic's Messages API. max_tokens: u32 = 64_000, + /// Extended-thinking mode. `.enabled` sends a manual thinking block with + /// `thinking_budget_tokens`; `.adaptive` lets Claude decide and uses + /// `effort` instead. `.disabled` omits thinking entirely. + thinking: Thinking = .enabled, + /// Effort level for adaptive thinking. Only emitted on the wire when + /// `thinking == .adaptive`; ignored otherwise. + effort: Effort = .medium, + /// Maximum tokens Claude may spend on internal reasoning when + /// `thinking == .enabled`. `null` falls back to `max_tokens - 1`. + /// Ignored when `thinking == .adaptive` or `.disabled`. + thinking_budget_tokens: ?u32 = 32_000, + /// When true and `thinking == .enabled`, sends the + /// `interleaved-thinking-2025-05-14` beta header so Claude can think + /// between tool calls. Ignored when `thinking == .adaptive` (interleaving + /// is automatic there) or `.disabled`. + thinking_interleaved: bool = false, }; /// Per-provider transport/auth/model configuration. Tagged by `APIStyle`. @@ -70,8 +108,8 @@ pub const ProviderConfig = union(APIStyle) { /// The wire-format provider identity for this config: the ground-truth /// `{api_style, base_url, model, reasoning}` that a turn is sent with. - /// Anthropic has no reasoning-effort knob, so it reports `.default`. - /// Borrowed slices; valid as long as the config is. + /// Anthropic carries thinking/effort/budget/interleaved instead of + /// reasoning. Borrowed slices; valid as long as the config is. pub fn wireIdentity(self: ProviderConfig) WireIdentity { return switch (self) { .openai_chat => |c| .{ @@ -84,7 +122,10 @@ pub const ProviderConfig = union(APIStyle) { .api_style = .anthropic_messages, .base_url = c.base_url, .model = c.model, - .reasoning = .default, + .thinking = c.thinking, + .effort = c.effort, + .thinking_budget_tokens = c.thinking_budget_tokens, + .thinking_interleaved = c.thinking_interleaved, }, }; } @@ -93,11 +134,24 @@ pub const ProviderConfig = union(APIStyle) { /// Wire-format provider identity (see `ProviderConfig.wireIdentity`). This /// is the same shape as `session_store.WireIdentity`; defined here to avoid /// a module cycle (config must not import session_store). +/// +/// OpenAI uses `reasoning`; Anthropic uses `thinking`/`effort`/ +/// `thinking_budget_tokens`/`thinking_interleaved`. Fields unused by the +/// active provider carry their zero/default values. pub const WireIdentity = struct { api_style: APIStyle, base_url: []const u8, model: []const u8, + /// OpenAI only. reasoning: ReasoningEffort = .default, + /// Anthropic only. + thinking: Thinking = .enabled, + /// Anthropic only; only meaningful when `thinking == .adaptive`. + effort: Effort = .medium, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_budget_tokens: ?u32 = 32_000, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_interleaved: bool = false, }; /// Compaction settings the agent consults when summarizing old history. @@ -220,6 +274,30 @@ test "ProviderConfig - anthropic_messages variant" { try t.expectEqual(APIStyle.anthropic_messages, cfg.style()); try t.expectEqualStrings("2023-06-01", cfg.anthropic_messages.api_version); try t.expectEqual(@as(u32, 64_000), cfg.anthropic_messages.max_tokens); + try t.expectEqual(Thinking.enabled, cfg.anthropic_messages.thinking); + try t.expectEqual(Effort.medium, cfg.anthropic_messages.effort); + try t.expectEqual(@as(?u32, 32_000), cfg.anthropic_messages.thinking_budget_tokens); + try t.expectEqual(false, cfg.anthropic_messages.thinking_interleaved); +} + +test "ProviderConfig - anthropic_messages wireIdentity carries thinking fields" { + const cfg: ProviderConfig = .{ .anthropic_messages = .{ + .api_key = "k", + .base_url = "https://api.anthropic.com", + .model = "claude-opus-4-8", + .thinking = .adaptive, + .effort = .high, + .thinking_budget_tokens = null, + .thinking_interleaved = false, + } }; + const id = cfg.wireIdentity(); + try t.expectEqual(APIStyle.anthropic_messages, id.api_style); + try t.expectEqual(Thinking.adaptive, id.thinking); + try t.expectEqual(Effort.high, id.effort); + try t.expectEqual(@as(?u32, null), id.thinking_budget_tokens); + try t.expectEqual(false, id.thinking_interleaved); + // reasoning field carries its zero default; not meaningful for Anthropic + try t.expectEqual(ReasoningEffort.default, id.reasoning); } test "ProviderConfig - openai_chat reasoning defaults to .default" { diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig index bbf8ea5..c77d7d2 100644 --- a/libpanto/src/file_system_jsonl_store.zig +++ b/libpanto/src/file_system_jsonl_store.zig @@ -1351,6 +1351,10 @@ pub const FileSystemJSONLStore = struct { .base_url = pm.identity.base_url, .model = pm.identity.model, .reasoning = pm.identity.reasoning, + .thinking = pm.identity.thinking, + .effort = pm.identity.effort, + .thinking_budget_tokens = pm.identity.thinking_budget_tokens, + .thinking_interleaved = pm.identity.thinking_interleaved, }; built += 1; } diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index d76793b..8e41695 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -75,15 +75,31 @@ pub const AnthropicMessagesRequest = struct { const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools); defer self.allocator.free(body); - const extra_headers = [_]http.Header{ + // Build headers. The four base headers are always present; the + // interleaved-thinking beta header is added only when the config + // explicitly requests manual extended thinking with interleaving. + // It is intentionally NOT sent for `.adaptive` (interleaving is + // automatic there and the header causes 400s on some backends) or + // `.disabled`. + var headers_buf: [5]http.Header = .{ .{ .name = "content-type", .value = "application/json" }, .{ .name = "accept", .value = "text/event-stream" }, .{ .name = "x-api-key", .value = self.config.api_key }, .{ .name = "anthropic-version", .value = self.config.api_version }, + undefined, // slot reserved for the optional beta header }; + const send_interleaved = self.config.thinking == .enabled and + self.config.thinking_interleaved; + if (send_interleaved) { + headers_buf[4] = .{ + .name = "anthropic-beta", + .value = "interleaved-thinking-2025-05-14", + }; + } + const extra_headers = headers_buf[0 .. if (send_interleaved) @as(usize, 5) else @as(usize, 4)]; rr.req = try self.http_client.request(.POST, uri, .{ - .extra_headers = &extra_headers, + .extra_headers = extra_headers, // Disable compression: gzip buffers small SSE frames, defeating // the streaming property we paid for `stream: true` to get. .headers = .{ .accept_encoding = .{ .override = "identity" } }, @@ -1095,3 +1111,56 @@ test "two streamed turns persist assistant replies in the conversation" { conv.messages.items[4].content.items[0].Text.items, ); } + +/// Helper: build the header slice exactly as `open` does, given a config, +/// and return whether the interleaved beta header is present. +/// This lets us test the header-selection logic without a live HTTP connection. +fn headerSliceIncludesInterleaved(cfg: *const config_mod.AnthropicMessagesConfig) bool { + const send_interleaved = cfg.thinking == .enabled and cfg.thinking_interleaved; + return send_interleaved; +} + +test "interleaved beta header: enabled when thinking=.enabled and interleaved=true" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .enabled, + .thinking_interleaved = true, + }; + try testing.expect(headerSliceIncludesInterleaved(&cfg)); +} + +test "interleaved beta header: absent when thinking=.enabled and interleaved=false" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .enabled, + .thinking_interleaved = false, + }; + try testing.expect(!headerSliceIncludesInterleaved(&cfg)); +} + +test "interleaved beta header: absent when thinking=.adaptive even if interleaved=true" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .adaptive, + .thinking_interleaved = true, + }; + // .adaptive does not send the header; interleaving is automatic there. + try testing.expect(!headerSliceIncludesInterleaved(&cfg)); +} + +test "interleaved beta header: absent when thinking=.disabled" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .disabled, + .thinking_interleaved = true, + }; + try testing.expect(!headerSliceIncludesInterleaved(&cfg)); +} diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 5488220..f9c2830 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -64,6 +64,8 @@ pub const OpenAIChatConfig = config_mod.OpenAIChatConfig; pub const AnthropicMessagesConfig = config_mod.AnthropicMessagesConfig; pub const APIStyle = config_mod.APIStyle; pub const ReasoningEffort = config_mod.ReasoningEffort; +pub const Thinking = config_mod.Thinking; +pub const Effort = config_mod.Effort; pub const CompactionConfig = config_mod.CompactionConfig; pub const RetryConfig = config_mod.RetryConfig; pub const WireIdentity = config_mod.WireIdentity; diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig index ca7046c..492f94b 100644 --- a/libpanto/src/session.zig +++ b/libpanto/src/session.zig @@ -27,6 +27,8 @@ const config = @import("config.zig"); pub const APIStyle = config.APIStyle; pub const ReasoningEffort = config.ReasoningEffort; +pub const Thinking = config.Thinking; +pub const Effort = config.Effort; /// Wire-format provider identity stamped on a message entry. This is the /// ground truth of which endpoint a turn was sent to — never a CLI config @@ -36,7 +38,17 @@ pub const WireStamp = struct { api_style: APIStyle, base_url: []const u8, // owned model: []const u8, // owned + /// OpenAI only. Defaults to `.default` (field omitted on the wire). reasoning: ReasoningEffort = .default, + /// Anthropic only. Defaults to `.enabled`. + thinking: Thinking = .enabled, + /// Anthropic only; only meaningful when `thinking == .adaptive`. + effort: Effort = .medium, + /// Anthropic only; only meaningful when `thinking == .enabled`. `null` + /// means "use the config default" (falls back to `max_tokens - 1`). + thinking_budget_tokens: ?u32 = 32_000, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_interleaved: bool = false, pub fn deinit(self: WireStamp, alloc: Allocator) void { alloc.free(self.base_url); @@ -52,6 +64,10 @@ pub const WireStamp = struct { .base_url = burl, .model = mdl, .reasoning = self.reasoning, + .thinking = self.thinking, + .effort = self.effort, + .thinking_budget_tokens = self.thinking_budget_tokens, + .thinking_interleaved = self.thinking_interleaved, }; } }; @@ -338,8 +354,35 @@ fn writeWireStamp(s: *std.json.Stringify, st: WireStamp) !void { try s.write(st.base_url); try s.objectField("model"); try s.write(st.model); - try s.objectField("reasoning"); - try s.write(@tagName(st.reasoning)); + // OpenAI: emit reasoning only when non-default (keeps logs compact). + if (st.reasoning != .default) { + try s.objectField("reasoning"); + try s.write(@tagName(st.reasoning)); + } + // Anthropic: emit thinking fields only when they differ from defaults. + if (st.thinking != .enabled) { + try s.objectField("thinking"); + try s.write(@tagName(st.thinking)); + } + if (st.effort != .medium) { + try s.objectField("effort"); + try s.write(@tagName(st.effort)); + } + if (st.thinking_budget_tokens) |b| { + if (b != 32_000) { + try s.objectField("thinkingBudgetTokens"); + try s.write(b); + } + } else { + // null means "use max_tokens - 1"; record the absence explicitly + // so round-trips preserve the null intent. + try s.objectField("thinkingBudgetTokens"); + try s.write(null); + } + if (st.thinking_interleaved) { + try s.objectField("thinkingInterleaved"); + try s.write(true); + } } fn writeDiskMessage(s: *std.json.Stringify, msg: StoredMessage) !void { @@ -572,12 +615,45 @@ fn parseWireStamp(allocator: Allocator, obj: std.json.ObjectMap) ParseError!?Wir errdefer allocator.free(base_url); const model = try dupeStringField(allocator, obj, "model"); errdefer allocator.free(model); + // OpenAI: absent reasoning defaults to .default. const reasoning: ReasoningEffort = blk: { const rv = obj.get("reasoning") orelse break :blk .default; if (rv != .string) break :blk .default; break :blk std.meta.stringToEnum(ReasoningEffort, rv.string) orelse .default; }; - return .{ .api_style = api_style, .base_url = base_url, .model = model, .reasoning = reasoning }; + // Anthropic: absent fields default to the same values as the config defaults. + const thinking: Thinking = blk: { + const tv = obj.get("thinking") orelse break :blk .enabled; + if (tv != .string) break :blk .enabled; + break :blk std.meta.stringToEnum(Thinking, tv.string) orelse .enabled; + }; + const effort: Effort = blk: { + const ev = obj.get("effort") orelse break :blk .medium; + if (ev != .string) break :blk .medium; + break :blk std.meta.stringToEnum(Effort, ev.string) orelse .medium; + }; + const thinking_budget_tokens: ?u32 = blk: { + const bv = obj.get("thinkingBudgetTokens") orelse break :blk 32_000; + if (bv == .null) break :blk null; + if (bv != .integer) break :blk 32_000; + if (bv.integer < 0) break :blk 32_000; + break :blk @intCast(bv.integer); + }; + const thinking_interleaved: bool = blk: { + const iv = obj.get("thinkingInterleaved") orelse break :blk false; + if (iv != .bool) break :blk false; + break :blk iv.bool; + }; + return .{ + .api_style = api_style, + .base_url = base_url, + .model = model, + .reasoning = reasoning, + .thinking = thinking, + .effort = effort, + .thinking_budget_tokens = thinking_budget_tokens, + .thinking_interleaved = thinking_interleaved, + }; } fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!StoredMessage { @@ -1366,3 +1442,148 @@ test "compactionSummary bridges in-memory <-> disk both directions" { defer inmem.deinit(a); try testing.expectEqualStrings("S1", inmem.CompactionSummary.text.items); } + +test "WireStamp: Anthropic non-default thinking fields round-trip" { + const a = testing.allocator; + + var content = try a.alloc(StoredContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "aa000001"), + .parent_id = null, + .timestamp = try dupe(a, "2026-06-01T00:00:00Z"), + }, + .stamp = .{ + .api_style = .anthropic_messages, + .base_url = try dupe(a, "https://api.anthropic.com"), + .model = try dupe(a, "claude-opus-4-8"), + .thinking = .adaptive, + .effort = .high, + .thinking_budget_tokens = null, + .thinking_interleaved = true, + }, + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + // Non-default fields must appear in the serialized line. + try testing.expect(std.mem.indexOf(u8, line, "\"thinking\":\"adaptive\"") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"effort\":\"high\"") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"thinkingBudgetTokens\":null") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"thinkingInterleaved\":true") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(APIStyle.anthropic_messages, got.api_style); + try testing.expectEqual(Thinking.adaptive, got.thinking); + try testing.expectEqual(Effort.high, got.effort); + try testing.expectEqual(@as(?u32, null), got.thinking_budget_tokens); + try testing.expectEqual(true, got.thinking_interleaved); + // reasoning carries its default (unused for Anthropic) + try testing.expectEqual(ReasoningEffort.default, got.reasoning); +} + +test "WireStamp: Anthropic stamp with all-default thinking fields omits non-essential keys" { + const a = testing.allocator; + + var content = try a.alloc(StoredContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "bb000002"), + .parent_id = null, + .timestamp = try dupe(a, "2026-06-01T00:00:00Z"), + }, + .stamp = .{ + .api_style = .anthropic_messages, + .base_url = try dupe(a, "https://api.anthropic.com"), + .model = try dupe(a, "claude-haiku-4-5"), + // All defaults: thinking=.enabled, effort=.medium, + // thinking_budget_tokens=32_000, thinking_interleaved=false + }, + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + // Default-valued fields should be omitted (keeps logs compact). + try testing.expect(std.mem.indexOf(u8, line, "thinking") == null); + try testing.expect(std.mem.indexOf(u8, line, "effort") == null); + try testing.expect(std.mem.indexOf(u8, line, "thinkingInterleaved") == null); + // thinkingBudgetTokens=32_000 is the default, should be omitted too. + try testing.expect(std.mem.indexOf(u8, line, "thinkingBudgetTokens") == null); + + // Round-trip: all defaults parse back correctly. + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(Thinking.enabled, got.thinking); + try testing.expectEqual(Effort.medium, got.effort); + try testing.expectEqual(@as(?u32, 32_000), got.thinking_budget_tokens); + try testing.expectEqual(false, got.thinking_interleaved); +} + +test "WireStamp: legacy Anthropic stamp (no thinking fields) parses with defaults" { + // Simulate a session log written before thinking fields were added. + const a = testing.allocator; + const line = + \\{"type":"message","id":"cc000003","parentId":null,"timestamp":"2026-06-01T00:00:00Z","apiStyle":"anthropic_messages","baseUrl":"https://api.anthropic.com","model":"claude-3-7-sonnet","message":{"role":"user","content":[{"type":"text","text":"hi"}]}} + ; + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(APIStyle.anthropic_messages, got.api_style); + try testing.expectEqual(Thinking.enabled, got.thinking); + try testing.expectEqual(Effort.medium, got.effort); + try testing.expectEqual(@as(?u32, 32_000), got.thinking_budget_tokens); + try testing.expectEqual(false, got.thinking_interleaved); +} + +test "WireStamp: OpenAI stamp is unchanged by Anthropic fields" { + const a = testing.allocator; + + var content = try a.alloc(StoredContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "dd000004"), + .parent_id = null, + .timestamp = try dupe(a, "2026-06-01T00:00:00Z"), + }, + .stamp = .{ + .api_style = .openai_chat, + .base_url = try dupe(a, "https://api.openai.com/v1"), + .model = try dupe(a, "gpt-4o"), + .reasoning = .high, + }, + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + // Anthropic fields should not appear for an OpenAI stamp. + try testing.expect(std.mem.indexOf(u8, line, "thinking") == null); + try testing.expect(std.mem.indexOf(u8, line, "effort") == null); + try testing.expect(std.mem.indexOf(u8, line, "thinkingBudget") == null); + try testing.expect(std.mem.indexOf(u8, line, "thinkingInterleaved") == null); + // reasoning=high should be present + try testing.expect(std.mem.indexOf(u8, line, "\"reasoning\":\"high\"") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(APIStyle.openai_chat, got.api_style); + try testing.expectEqual(ReasoningEffort.high, got.reasoning); +} |
