summaryrefslogtreecommitdiff
path: root/libpanto/src/session.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/session.zig')
-rw-r--r--libpanto/src/session.zig227
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);
+}