diff options
Diffstat (limited to 'libpanto/src/session.zig')
| -rw-r--r-- | libpanto/src/session.zig | 227 |
1 files changed, 224 insertions, 3 deletions
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); +} |
