summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--libpanto/src/agent.zig7
-rw-r--r--libpanto/src/anthropic_messages_json.zig49
-rw-r--r--libpanto/src/auth.zig6
-rw-r--r--libpanto/src/compaction.zig10
-rw-r--r--libpanto/src/conversation.zig26
-rw-r--r--libpanto/src/file_system_jsonl_store.zig205
-rw-r--r--libpanto/src/image.zig50
-rw-r--r--libpanto/src/openai_chat_json.zig25
-rw-r--r--libpanto/src/openai_responses_json.zig30
-rw-r--r--libpanto/src/pricing.zig7
-rw-r--r--libpanto/src/provider.zig121
-rw-r--r--libpanto/src/provider_anthropic_messages.zig68
-rw-r--r--libpanto/src/provider_openai_chat.zig80
-rw-r--r--libpanto/src/provider_openai_responses.zig53
-rw-r--r--libpanto/src/session.zig8
-rw-r--r--scripts/analyze_pi_sessions.py458
16 files changed, 223 insertions, 980 deletions
diff --git a/libpanto/src/agent.zig b/libpanto/src/agent.zig
index e689924..faf0a4a 100644
--- a/libpanto/src/agent.zig
+++ b/libpanto/src/agent.zig
@@ -443,13 +443,8 @@ pub const Agent = struct {
self._persisted_through = end;
}
- /// Persist the messages a turn produced. When the turn auto-compacted,
- /// message indices shifted (the conversation was rewritten to
fn hasToolUseBlock(msg: conversation.Message) bool {
- for (msg.content.items) |block| {
- if (block == .ToolUse) return true;
- }
- return false;
+ return toolUseCount(msg) > 0;
}
/// Open one provider response with the configured retry policy, pushing
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
index 5f119f3..5085149 100644
--- a/libpanto/src/anthropic_messages_json.zig
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -16,6 +16,7 @@ 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
@@ -147,44 +148,6 @@ pub fn serializeRequest(
return try aw.toOwnedSlice();
}
-/// Splice a pre-encoded JSON value into the current stringifier position.
-/// On parse failure, emit `{}` so we never produce invalid wire JSON.
-fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void {
- var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
- defer arena.deinit();
- const parsed = std.json.parseFromSlice(
- std.json.Value,
- arena.allocator(),
- raw,
- .{},
- ) catch {
- try s.beginObject();
- try s.endObject();
- return;
- };
- try s.write(parsed.value);
-}
-
-fn writeToolUseInput(s: *std.json.Stringify, raw: []const u8) !void {
- var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
- defer arena.deinit();
- const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), raw, .{}) catch {
- try s.beginObject();
- try s.objectField("_invalid_input");
- try s.write(raw);
- try s.endObject();
- return;
- };
- if (parsed.value != .object) {
- try s.beginObject();
- try s.objectField("_invalid_input");
- try s.write(raw);
- try s.endObject();
- return;
- }
- try s.write(parsed.value);
-}
-
/// 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
@@ -335,7 +298,7 @@ fn writeBlock(
try s.beginObject();
try s.endObject();
} else {
- try writeToolUseInput(s, input_bytes);
+ try writeRawJson(s, input_bytes);
}
if (mark_cache) try writeCacheControl(s);
try s.endObject();
@@ -455,7 +418,6 @@ pub const StreamUsage = struct {
pub const StreamEvent = union(StreamEventTag) {
message_start: struct {
- role: ?[]const u8 = null,
usage: StreamUsage = .{},
},
content_block_start: struct {
@@ -509,19 +471,15 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !ParsedStream
const ty = type_v.string;
if (std.mem.eql(u8, ty, "message_start")) {
- var role: ?[]const u8 = null;
var usage: StreamUsage = .{};
if (root.object.get("message")) |m| {
if (m == .object) {
- if (m.object.get("role")) |r| {
- if (r == .string) role = r.string;
- }
if (m.object.get("usage")) |u| {
if (u == .object) usage = parseStreamUsage(u.object);
}
}
}
- return .{ .parsed = parsed, .event = .{ .message_start = .{ .role = role, .usage = usage } } };
+ return .{ .parsed = parsed, .event = .{ .message_start = .{ .usage = usage } } };
}
if (std.mem.eql(u8, ty, "content_block_start")) {
@@ -1119,7 +1077,6 @@ test "parseStreamEvent - message_start" {
var pe = try parseStreamEvent(allocator, payload);
defer pe.deinit();
try testing.expectEqual(StreamEventTag.message_start, @as(StreamEventTag, pe.event));
- try testing.expectEqualStrings("assistant", pe.event.message_start.role.?);
}
test "parseStreamEvent - content_block_start text" {
diff --git a/libpanto/src/auth.zig b/libpanto/src/auth.zig
index 5b65bf4..4c1723b 100644
--- a/libpanto/src/auth.zig
+++ b/libpanto/src/auth.zig
@@ -567,11 +567,7 @@ pub fn login(
const deadline_ns = start_ns + 15 * std.time.ns_per_min;
while (true) {
io.sleep(.fromSeconds(@intCast(da.interval_secs)), .real) catch {};
- const result = pollOnce(arena, client, oauth, da, headers) catch |err| switch (err) {
- // A transient transport hiccup mid-poll: keep waiting.
- error.AuthFlowFailed => return err,
- else => return err,
- };
+ const result = try pollOnce(arena, client, oauth, da, headers);
switch (result) {
.pending => {},
.done => |toks| return toks,
diff --git a/libpanto/src/compaction.zig b/libpanto/src/compaction.zig
index dfa3242..f620f30 100644
--- a/libpanto/src/compaction.zig
+++ b/libpanto/src/compaction.zig
@@ -222,14 +222,8 @@ pub fn computeSplit(
) Split {
if (usages) |u| std.debug.assert(u.len == messages.len);
- const active_start = blk: {
- if (conversation.latestCompactionIndex(messages)) |anchor| {
- // The summary message itself anchors the window; active turns
- // begin after it.
- break :blk anchor + 1;
- }
- break :blk 0;
- };
+ // The summary message itself anchors the window; active turns begin after it.
+ const active_start = if (conversation.latestCompactionIndex(messages)) |anchor| anchor + 1 else 0;
// Identify turn boundaries within the active window. A turn starts at a
// user message that is NOT purely tool-results (a fresh human/user
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
index 2c63f19..ea4309c 100644
--- a/libpanto/src/conversation.zig
+++ b/libpanto/src/conversation.zig
@@ -351,15 +351,7 @@ pub const Conversation = struct {
/// Blocks must use this conversation's allocator for any owned bytes,
/// since the conversation will free them with that allocator.
pub fn addUserMessage(self: *Conversation, blocks: []const ContentBlock) !void {
- var content: std.ArrayList(ContentBlock) = .empty;
- try content.ensureTotalCapacity(self.allocator, blocks.len);
- for (blocks) |block| {
- content.appendAssumeCapacity(block);
- }
- try self.messages.append(self.allocator, .{
- .role = .user,
- .content = content,
- });
+ return self.addMessage(.user, blocks, null);
}
/// Append a `user`-role message carrying a single `.CompactionSummary`
@@ -388,16 +380,7 @@ pub const Conversation = struct {
blocks: []const ContentBlock,
usage: ?Usage,
) !void {
- var content: std.ArrayList(ContentBlock) = .empty;
- try content.ensureTotalCapacity(self.allocator, blocks.len);
- for (blocks) |block| {
- content.appendAssumeCapacity(block);
- }
- try self.messages.append(self.allocator, .{
- .role = .assistant,
- .content = content,
- .usage = usage,
- });
+ return self.addMessage(.assistant, blocks, usage);
}
/// Append a message of any role from a slice of content blocks, with
@@ -497,10 +480,7 @@ pub fn effectiveSystemBlocks(
for (msg.content.items) |block| {
switch (block) {
.System => |sb| {
- switch (sb.mode) {
- .append => {},
- .replace => out.clearRetainingCapacity(),
- }
+ if (sb.mode == .replace) out.clearRetainingCapacity();
try out.append(alloc, sb.text.items);
},
// Be tolerant of plain `.Text` blocks on a system message
diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig
index 50587f0..5b7f450 100644
--- a/libpanto/src/file_system_jsonl_store.zig
+++ b/libpanto/src/file_system_jsonl_store.zig
@@ -372,18 +372,15 @@ pub const SessionFile = struct {
try truncateFileTo(io, path_copy, valid_bytes);
}
- var migrated_header = header;
- var did_migrate = migrate(allocator, &migrated_header, &entries);
+ // Drop assistant tool_use blocks left dangling (no matching
+ // tool_result) by an interrupted prior turn. If anything changed,
+ // rebuild the id index and rewrite the file once.
if (elideDanglingToolUses(allocator, &entries)) {
- did_migrate = true;
- }
- // We didn't reassign by_id during migration; rebuild if needed.
- if (did_migrate) {
by_id.clearRetainingCapacity();
for (entries.items, 0..) |e, i| {
try by_id.put(e.base().id, i);
}
- try rewriteFile(allocator, io, path_copy, migrated_header, entries.items);
+ try rewriteFile(allocator, io, path_copy, header, entries.items);
}
const leaf_id: ?[]const u8 = if (entries.items.len > 0)
@@ -400,7 +397,7 @@ pub const SessionFile = struct {
.io = io,
.session_dir = dir,
.session_file = path_copy,
- .header = migrated_header,
+ .header = header,
.entries = entries,
.by_id = by_id,
.leaf_id = leaf_id,
@@ -420,131 +417,30 @@ pub const SessionFile = struct {
// ---------- Accessors ----------
- pub fn getMetadata(self: *const SessionFile) ?[]const u8 {
- return self.header.metadata;
- }
-
- pub fn getSessionId(self: *const SessionFile) []const u8 {
- return self.header.id;
- }
-
pub fn getSessionFile(self: *const SessionFile) []const u8 {
return self.session_file;
}
- pub fn getSessionDir(self: *const SessionFile) []const u8 {
- return self.session_dir;
- }
-
- pub fn getLeafId(self: *const SessionFile) ?[]const u8 {
- return self.leaf_id;
- }
-
- pub fn getEntry(self: *const SessionFile, id: []const u8) ?*const SessionEntry {
- const idx = self.by_id.get(id) orelse return null;
- return &self.entries.items[idx];
- }
-
- pub fn getEntries(self: *const SessionFile) []const SessionEntry {
- return self.entries.items;
- }
-
pub fn isFlushed(self: *const SessionFile) bool {
return self.flushed;
}
- // ---------- Active model resolution ----------
-
- /// Determine the active provider/model by walking entries leaf→root
- /// and finding the last user-message entry with provider/model
- /// stamped. Returns null only when no user message has been appended
- /// yet, which is only reachable on a freshly-`init`'d session before
- /// the first user prompt (and therefore before any disk flush).
- ///
- /// Returns a borrowed stamp owned by the manager; do not free.
- pub fn activeStamp(self: *const SessionFile) ?session_mod.WireStamp {
- var i = self.entries.items.len;
- while (i > 0) : (i -= 1) {
- const e = self.entries.items[i - 1];
- switch (e) {
- .message => |m| {
- if (m.stamp) |st| return st;
- },
- }
- }
- return null;
- }
-
// ---------- Appending ----------
- /// Append a message entry. `msg` is consumed (ownership transferred)
- /// regardless of success — on error, the message is deinit'd before
- /// the error is returned.
- ///
- /// If `flushed`: writes the new line immediately.
- /// If not flushed and `msg.role == .assistant`: writes the header +
- /// all buffered entries + the new entry, then sets `flushed`.
- /// Otherwise: buffers in memory only.
+ /// Append a single message, returning its entry id. A thin wrapper over
+ /// `appendMessagesAtomic` (which handles `len == 1`); used by the tests.
+ /// `msg` is consumed (ownership transferred).
pub fn appendMessage(
self: *SessionFile,
msg: StoredMessage,
// Wire-format provider identity for the entry. Null on system
- // messages. Borrowed; duplicated here into the entry.
+ // messages. Borrowed; duplicated into the entry.
stamp: ?session_mod.WireStamp,
) ![]const u8 {
- // Build the entry up-front, taking ownership of the inputs.
- var msg_local = msg;
- errdefer msg_local.deinit(self.allocator);
-
- const id_buf = try self.newEntryId();
- errdefer self.allocator.free(id_buf);
-
- const timestamp = try isoTimestamp(self.allocator, self.io);
- errdefer self.allocator.free(timestamp);
-
- const parent_id_copy: ?[]const u8 = if (self.leaf_id) |l| try self.allocator.dupe(u8, l) else null;
- errdefer if (parent_id_copy) |p| self.allocator.free(p);
-
- const stamp_copy: ?session_mod.WireStamp = if (stamp) |st| try st.dupe(self.allocator) else null;
- errdefer if (stamp_copy) |st| st.deinit(self.allocator);
-
- const entry: SessionEntry = .{ .message = .{
- .base = .{ .id = id_buf, .parent_id = parent_id_copy, .timestamp = timestamp },
- .stamp = stamp_copy,
- .message = msg_local,
- } };
-
- // The entry now owns msg_local + id_buf + timestamp + parent_id_copy +
- // provider/model copies. Cancel the errdefers individually.
- // (Zig's errdefer behavior: they only run on error returns; pushing the
- // entry into entries.items before any further fallible step means an
- // error in by_id.put() would double-free. Instead, do the put first
- // against a not-yet-stored id.)
-
- const idx = self.entries.items.len;
-
- // Ensure capacity before touching anything.
- try self.entries.ensureUnusedCapacity(self.allocator, 1);
- try self.by_id.ensureUnusedCapacity(1);
-
- // Persist BEFORE inserting into the in-memory structures, so that on
- // I/O failure we don't have a dangling in-memory entry the caller
- // thinks was saved. (Failure leaves the file unchanged for an
- // unflushed session, and unchanged-except-for-EOF for a flushed one.)
- const is_assistant = entry.message.message.role == .assistant;
- if (self.flushed) {
- try self.persistEntry(entry);
- } else if (is_assistant) {
- try self.flushBuffered(entry);
- }
- // If not flushed and not assistant: nothing to do; the entry will be
- // flushed alongside the eventual first assistant entry.
-
- // Now insert into in-memory structures. All allocations are kept.
- self.entries.appendAssumeCapacity(entry);
- self.by_id.putAssumeCapacity(entry.base().id, idx);
- self.leaf_id = entry.base().id;
- return entry.base().id;
+ var msgs = [_]StoredMessage{msg};
+ const stamps = [_]?session_mod.WireStamp{stamp};
+ try self.appendMessagesAtomic(&msgs, &stamps);
+ return self.leaf_id.?;
}
/// Returns a freshly allocated 8-character hex id, guaranteed not to
@@ -616,16 +512,6 @@ pub const SessionFile = struct {
self.written_bytes = offset;
}
- fn flushBuffered(self: *SessionFile, final_entry: SessionEntry) !void {
- try self.flushBufferedMany(&.{final_entry});
- }
-
- /// Append a single line for `entry` to the open session file. Caller
- /// must have already verified `flushed`.
- fn persistEntry(self: *SessionFile, entry: SessionEntry) !void {
- try self.persistEntries(&.{entry});
- }
-
pub fn appendMessagesAtomic(
self: *SessionFile,
messages: []StoredMessage,
@@ -801,24 +687,9 @@ fn appendMessageToConv(
}
// =============================================================================
-// Migration
+// Entry repair on load
// =============================================================================
-/// Future format migrations land here. Returns true if anything changed
-/// (which triggers a one-time file rewrite).
-fn migrate(
- allocator: Allocator,
- header: *SessionHeader,
- entries: *std.ArrayList(SessionEntry),
-) bool {
- _ = allocator;
- _ = entries;
- if (header.version >= CURRENT_VERSION) return false;
- // No earlier versions exist yet. When v2 lands, transform v1 entries
- // here and bump `header.version`.
- return false;
-}
-
fn elideDanglingToolUses(allocator: Allocator, entries: *std.ArrayList(SessionEntry)) bool {
var needed: std.StringHashMap(void) = .init(allocator);
defer needed.deinit();
@@ -1565,25 +1436,25 @@ test "SessionFile: full flow — buffer, flush on assistant, append, resume" {
);
defer mgr.deinit();
- // System message — non-assistant, should NOT trigger flush.
+ // First append flushes the header + entry to disk immediately.
const sys_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
sys_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "You are helpful.") } };
_ = try mgr.appendMessage(
.{ .role = .system, .content = sys_blocks },
null,
);
- try testing.expect(!mgr.isFlushed());
+ try testing.expect(mgr.isFlushed());
- // User message — also doesn't flush.
+ // User message — appended to the already-flushed file.
const usr_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
usr_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hi there") } };
_ = try mgr.appendMessage(
.{ .role = .user, .content = usr_blocks },
oaStamp(),
);
- try testing.expect(!mgr.isFlushed());
+ try testing.expect(mgr.isFlushed());
- // Assistant message — triggers flush.
+ // Assistant message.
const a_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
a_blocks[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "hello") } };
_ = try mgr.appendMessage(
@@ -1608,7 +1479,7 @@ test "SessionFile: full flow — buffer, flush on assistant, append, resume" {
oaStamp(),
);
- try testing.expectEqual(@as(usize, 5), mgr.getEntries().len);
+ try testing.expectEqual(@as(usize, 5), mgr.entries.items.len);
break :blk try testing.allocator.dupe(u8, mgr.getSessionFile());
};
@@ -1630,14 +1501,14 @@ test "SessionFile: full flow — buffer, flush on assistant, append, resume" {
var resumed = try SessionFile.open(testing.allocator, io, session_file);
defer resumed.deinit();
try testing.expect(resumed.isFlushed());
- try testing.expectEqual(@as(usize, 5), resumed.getEntries().len);
- try testing.expectEqualStrings("{\"cwd\":\"/proj/foo\"}", resumed.getMetadata().?);
+ try testing.expectEqual(@as(usize, 5), resumed.entries.items.len);
+ try testing.expectEqualStrings("{\"cwd\":\"/proj/foo\"}", resumed.header.metadata.?);
// Continue the conversation.
const u_three = try testing.allocator.alloc(StoredContentBlock, 1);
u_three[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "thanks") } };
_ = try resumed.appendMessage(.{ .role = .user, .content = u_three }, oaStamp());
- try testing.expectEqual(@as(usize, 6), resumed.getEntries().len);
+ try testing.expectEqual(@as(usize, 6), resumed.entries.items.len);
}
test "SessionFile: assistant message tags the message metadata and the entry leaf id is the assistant entry" {
@@ -1660,15 +1531,27 @@ test "SessionFile: assistant message tags the message metadata and the entry lea
const asst_id = try mgr.appendMessage(.{ .role = .assistant, .content = a_blocks }, null);
// Leaf is the assistant entry.
- try testing.expectEqualStrings(asst_id, mgr.getLeafId().?);
+ try testing.expectEqualStrings(asst_id, mgr.leaf_id.?);
// Parent of assistant is the user entry.
- const assistant_entry = mgr.getEntry(asst_id).?;
+ const assistant_entry = mgr.entries.items[mgr.by_id.get(asst_id).?];
try testing.expectEqualStrings(user_id, assistant_entry.base().parent_id.?);
// User entry's parent is null (no system).
- const user_entry = mgr.getEntry(user_id).?;
+ const user_entry = mgr.entries.items[mgr.by_id.get(user_id).?];
try testing.expect(user_entry.base().parent_id == null);
}
+/// Test helper: the active provider/model stamp — the last entry carrying a
+/// wire stamp, walking leaf→root. Null when no stamped message exists yet.
+fn activeStamp(sf: *const SessionFile) ?session_mod.WireStamp {
+ var i = sf.entries.items.len;
+ while (i > 0) : (i -= 1) {
+ switch (sf.entries.items[i - 1]) {
+ .message => |m| if (m.stamp) |st| return st,
+ }
+ }
+ return null;
+}
+
test "SessionFile: activeStamp is null before any user message, then tracks the latest user stamp" {
const io = testing.io;
@@ -1681,7 +1564,7 @@ test "SessionFile: activeStamp is null before any user message, then tracks the
defer mgr.deinit();
// No user messages yet — there is no "active" model on disk yet.
- try testing.expect(mgr.activeStamp() == null);
+ try testing.expect(activeStamp(&mgr) == null);
// Stamp a user message with anthropic.
const u_blocks = try testing.allocator.alloc(StoredContentBlock, 1);
@@ -1689,7 +1572,7 @@ test "SessionFile: activeStamp is null before any user message, then tracks the
_ = try mgr.appendMessage(.{ .role = .user, .content = u_blocks }, anStamp());
{
- const am = mgr.activeStamp().?;
+ const am = activeStamp(&mgr).?;
try testing.expectEqual(session_mod.APIStyle.anthropic_messages, am.api_style);
try testing.expectEqualStrings("claude-sonnet-4-20250514", am.model);
}
@@ -1770,7 +1653,7 @@ test "SessionFile: crash recovery truncates corrupted trailing line" {
// Now resume — the partial line should be truncated.
var resumed = try SessionFile.open(testing.allocator, io, session_file);
defer resumed.deinit();
- try testing.expectEqual(@as(usize, 2), resumed.getEntries().len);
+ try testing.expectEqual(@as(usize, 2), resumed.entries.items.len);
// And the file on disk should match.
{
@@ -1895,7 +1778,7 @@ test "SessionFile: tool-use round-trip — assistant w/ ToolUse, user w/ ToolRes
// Reopen and verify content blocks survive.
var resumed = try SessionFile.open(testing.allocator, io, session_file);
defer resumed.deinit();
- const entries = resumed.getEntries();
+ const entries = resumed.entries.items;
try testing.expectEqual(@as(usize, 4), entries.len);
// [1] = assistant with ToolUse
@@ -1951,7 +1834,7 @@ test "SessionFile: linear chain — each entry's parent_id is the previous entry
a_two[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a2") } };
_ = try mgr.appendMessage(.{ .role = .assistant, .content = a_two }, null);
- const entries = mgr.getEntries();
+ const entries = mgr.entries.items;
try testing.expectEqual(@as(usize, 5), entries.len);
try testing.expect(entries[0].base().parent_id == null);
for (entries[1..], 1..) |e, i| {
@@ -1977,7 +1860,7 @@ test "resolveSessionId: unique prefix → match, ambiguous → error" {
a[0] = .{ .text = .{ .text = try testing.allocator.dupe(u8, "a") } };
_ = try mgr.appendMessage(.{ .role = .assistant, .content = a }, null);
- const id = mgr.getSessionId();
+ const id = mgr.header.id;
const prefix = id[0..8];
const resolved = try resolveSessionId(testing.allocator, io, sessions, prefix);
diff --git a/libpanto/src/image.zig b/libpanto/src/image.zig
index 0367373..26474d1 100644
--- a/libpanto/src/image.zig
+++ b/libpanto/src/image.zig
@@ -2,8 +2,8 @@
//!
//! Two responsibilities:
//!
-//! 1. `detectMediaType` — identify an attachment's MIME type from its
-//! leading bytes (magic numbers), not its file extension.
+//! 1. `detectCodec` — identify an attachment's codec from its leading
+//! bytes (magic numbers), not its file extension.
//! 2. `maybeResize` — bound raster images to `max_dim` on each side so a
//! single screenshot can't blow out the model's context. PDFs and
//! already-small images pass through untouched.
@@ -29,10 +29,9 @@ const jpeg_quality: c_int = 80;
pub const Codec = enum { png, jpeg, gif, bmp, webp, pdf };
-/// Detect a supported media type from leading bytes. Returns null when the
-/// bytes don't match any known signature.
-pub fn detectMediaType(bytes: []const u8) ?[]const u8 {
- return switch (detectCodec(bytes) orelse return null) {
+/// The MIME type string for a codec (static; do not free).
+pub fn mediaTypeForCodec(codec: Codec) []const u8 {
+ return switch (codec) {
.png => "image/png",
.jpeg => "image/jpeg",
.gif => "image/gif",
@@ -88,28 +87,27 @@ pub const Processed = struct {
/// detection recognizes the bytes — the caller decides how to surface
/// that (e.g. drop the attachment, or fall back to text).
pub fn process(allocator: Allocator, bytes: []const u8, hint: ?[]const u8) !Processed {
- const media_type = blk: {
+ const codec = blk: {
if (hint) |h| {
- if (codecForMediaType(h) != null) break :blk h;
+ if (codecForMediaType(h)) |hinted| break :blk hinted;
}
- break :blk detectMediaType(bytes) orelse return error.UnknownMediaType;
+ break :blk detectCodec(bytes) orelse return error.UnknownMediaType;
};
- return maybeResize(allocator, bytes, media_type);
+ return maybeResize(allocator, bytes, codec);
}
/// Resize `bytes` so neither dimension exceeds `max_dim`, preserving the
/// input codec where possible. Returns an owned copy of the (possibly
/// unchanged) bytes plus the resulting media type.
///
-/// - PDF or unknown: returned verbatim (a copy), media type unchanged.
+/// - PDF: returned verbatim (a copy), media type unchanged.
/// - raster <= max_dim on both sides: returned verbatim (a copy) — we
/// skip the decode/encode round-trip to avoid quality loss + CPU.
/// - stb-supported raster larger than max_dim: decode -> resize -> same
/// codec.
/// - WEBP larger than max_dim: jebp decode -> resize -> JPEG.
-pub fn maybeResize(allocator: Allocator, bytes: []const u8, media_type: []const u8) !Processed {
- const codec = codecForMediaType(media_type) orelse
- return .{ .media_type = media_type, .data = try allocator.dupe(u8, bytes) };
+pub fn maybeResize(allocator: Allocator, bytes: []const u8, codec: Codec) !Processed {
+ const media_type = mediaTypeForCodec(codec);
if (codec == .pdf)
return .{ .media_type = media_type, .data = try allocator.dupe(u8, bytes) };
@@ -268,16 +266,16 @@ fn resizeWebp(allocator: Allocator, bytes: []const u8, media_type: []const u8) !
const testing = std.testing;
-test "detectMediaType - magic bytes" {
- try testing.expectEqualStrings("image/png", detectMediaType(&.{ 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }).?);
- try testing.expectEqualStrings("image/jpeg", detectMediaType(&.{ 0xFF, 0xD8, 0xFF, 0xE0 }).?);
- try testing.expectEqualStrings("image/gif", detectMediaType("GIF89a....").?);
- try testing.expectEqualStrings("image/bmp", detectMediaType("BM....").?);
- try testing.expectEqualStrings("application/pdf", detectMediaType("%PDF-1.7").?);
+test "detectCodec + mediaTypeForCodec - magic bytes" {
+ try testing.expectEqualStrings("image/png", mediaTypeForCodec(detectCodec(&.{ 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A }).?));
+ try testing.expectEqualStrings("image/jpeg", mediaTypeForCodec(detectCodec(&.{ 0xFF, 0xD8, 0xFF, 0xE0 }).?));
+ try testing.expectEqualStrings("image/gif", mediaTypeForCodec(detectCodec("GIF89a....").?));
+ try testing.expectEqualStrings("image/bmp", mediaTypeForCodec(detectCodec("BM....").?));
+ try testing.expectEqualStrings("application/pdf", mediaTypeForCodec(detectCodec("%PDF-1.7").?));
const webp = "RIFF" ++ &[_]u8{ 0, 0, 0, 0 } ++ "WEBP";
- try testing.expectEqualStrings("image/webp", detectMediaType(webp).?);
- try testing.expect(detectMediaType("not an image") == null);
- try testing.expect(detectMediaType(&.{0x89}) == null);
+ try testing.expectEqualStrings("image/webp", mediaTypeForCodec(detectCodec(webp).?));
+ try testing.expect(detectCodec("not an image") == null);
+ try testing.expect(detectCodec(&.{0x89}) == null);
}
test "targetDims - skip when small, scale when large preserving aspect" {
@@ -315,7 +313,7 @@ test "process - detects type from raw bytes when hint absent" {
test "maybeResize - PDF passes through unchanged" {
const a = testing.allocator;
const pdf = "%PDF-1.7\nfake pdf body";
- const out = try maybeResize(a, pdf, "application/pdf");
+ const out = try maybeResize(a, pdf, .pdf);
defer a.free(out.data);
try testing.expectEqualStrings("application/pdf", out.media_type);
try testing.expectEqualStrings(pdf, out.data);
@@ -334,7 +332,7 @@ test "maybeResize - small PNG round-trips, large PNG shrinks and stays PNG" {
var sctx = StbWriteCtx{ .list = &small_png, .allocator = a };
try testing.expect(c.stbi_write_png_to_func(stbWriteCb, &sctx, small_w, small_h, 4, &small_px, small_w * 4) != 0);
- const small_out = try maybeResize(a, small_png.items, "image/png");
+ const small_out = try maybeResize(a, small_png.items, .png);
defer a.free(small_out.data);
try testing.expectEqualStrings("image/png", small_out.media_type);
// Small image is returned verbatim (byte-identical copy).
@@ -351,7 +349,7 @@ test "maybeResize - small PNG round-trips, large PNG shrinks and stays PNG" {
var bctx = StbWriteCtx{ .list = &big_png, .allocator = a };
try testing.expect(c.stbi_write_png_to_func(stbWriteCb, &bctx, big_w, big_h, 4, big_px.ptr, big_w * 4) != 0);
- const big_out = try maybeResize(a, big_png.items, "image/png");
+ const big_out = try maybeResize(a, big_png.items, .png);
defer a.free(big_out.data);
try testing.expectEqualStrings("image/png", big_out.media_type);
const dims = probeDims(big_out.data).?;
diff --git a/libpanto/src/openai_chat_json.zig b/libpanto/src/openai_chat_json.zig
index 8af939f..f00c97f 100644
--- a/libpanto/src/openai_chat_json.zig
+++ b/libpanto/src/openai_chat_json.zig
@@ -11,6 +11,7 @@ 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;
/// A single parsed streaming chunk. Fields are populated only when present
/// in the wire payload; null fields signal "not in this chunk".
@@ -173,30 +174,6 @@ pub fn serializeRequest(
return try aw.toOwnedSlice();
}
-/// Emit a pre-encoded JSON value into the current stringifier position.
-///
-/// `std.json.Stringify.write` can only emit Zig values; we need to splice
-/// arbitrary user-supplied JSON (tool schemas, parsed tool inputs) into
-/// the output. We do that by parsing the bytes into `std.json.Value` and
-/// asking the stringifier to write them. If parsing fails, fall back to
-/// emitting an empty object so we never produce invalid JSON on the wire.
-fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void {
- // Use the stringifier's own allocator path via a scratch arena.
- var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
- defer arena.deinit();
- const parsed = std.json.parseFromSlice(
- std.json.Value,
- arena.allocator(),
- raw,
- .{},
- ) catch {
- try s.beginObject();
- try s.endObject();
- return;
- };
- try s.write(parsed.value);
-}
-
/// Emit one `Conversation.Message` as one or more wire-level messages.
///
/// OpenAI's wire format is awkward here: a single logical `user` turn that
diff --git a/libpanto/src/openai_responses_json.zig b/libpanto/src/openai_responses_json.zig
index fb98fe1..cb58c2c 100644
--- a/libpanto/src/openai_responses_json.zig
+++ b/libpanto/src/openai_responses_json.zig
@@ -25,6 +25,7 @@ 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
@@ -288,17 +289,6 @@ fn concatTextBlocks(
}
}
-fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void {
- var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
- defer arena.deinit();
- const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), raw, .{}) catch {
- try s.beginObject();
- try s.endObject();
- return;
- };
- try s.write(parsed.value);
-}
-
// ===========================================================================
// Streaming event parsing
// ===========================================================================
@@ -325,11 +315,8 @@ pub const StreamEvent = struct {
/// `output_text`/`reasoning_summary` delta, or function-call argument
/// fragment.
delta: ?[]const u8 = null,
- /// Item id (`response.output_item.added/done`, function-call argument
- /// deltas reference `item_id`).
- item_id: ?[]const u8 = null,
- /// Output array index. Some function-call argument events identify the
- /// item by this instead of repeating `item_id`.
+ /// Output array index. The stable key for a tool call across all of its
+ /// events (`item_id` is unreliable on the Copilot proxy, so unused).
output_index: ?usize = null,
/// Item type on add/done: "message" | "function_call" | "reasoning".
item_type: ?[]const u8 = null,
@@ -359,8 +346,6 @@ pub const StreamEvent = struct {
pub const OutputItem = struct {
output_index: usize,
- item_id: ?[]const u8 = null,
- item_type: ?[]const u8 = null,
call_id: ?[]const u8 = null,
name: ?[]const u8 = null,
arguments: ?[]const u8 = null,
@@ -390,24 +375,20 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !StreamEvent
if (std.mem.eql(u8, type_str, "response.output_text.delta")) {
ev.kind = .output_text_delta;
ev.delta = strField(obj, "delta");
- ev.item_id = strField(obj, "item_id");
ev.output_index = usizeField(obj, "output_index");
} else if (std.mem.eql(u8, type_str, "response.reasoning_summary_text.delta")) {
ev.kind = .reasoning_summary_delta;
ev.delta = strField(obj, "delta");
- ev.item_id = strField(obj, "item_id");
ev.output_index = usizeField(obj, "output_index");
} else if (std.mem.eql(u8, type_str, "response.function_call_arguments.delta")) {
ev.kind = .function_call_arguments_delta;
ev.delta = strField(obj, "delta") orelse
strField(obj, "arguments_delta") orelse
strField(obj, "arguments");
- ev.item_id = strField(obj, "item_id");
ev.output_index = usizeField(obj, "output_index");
} else if (std.mem.eql(u8, type_str, "response.function_call_arguments.done")) {
ev.kind = .function_call_arguments_done;
ev.arguments = strField(obj, "arguments");
- ev.item_id = strField(obj, "item_id");
ev.output_index = usizeField(obj, "output_index");
} else if (std.mem.eql(u8, type_str, "response.output_item.added")) {
ev.kind = .output_item_added;
@@ -437,7 +418,6 @@ fn readItem(allocator: Allocator, obj: std.json.ObjectMap, ev: *StreamEvent) !vo
const item = obj.get("item") orelse return;
if (item != .object) return;
const io = item.object;
- ev.item_id = strField(io, "id");
ev.item_type = strField(io, "type");
ev.item_phase = strField(io, "phase");
ev.call_id = strField(io, "call_id");
@@ -495,8 +475,6 @@ fn readCompletedOutput(allocator: Allocator, obj: std.json.ObjectMap, ev: *Strea
if (!std.mem.eql(u8, item_type, "function_call")) continue;
try items.append(allocator, .{
.output_index = i,
- .item_id = strField(v.object, "id"),
- .item_type = item_type,
.call_id = strField(v.object, "call_id"),
.name = strField(v.object, "name"),
.arguments = strField(v.object, "arguments"),
@@ -762,7 +740,6 @@ test "responses parseStreamEvent - function_call item added/done + args delta" {
);
defer d.deinit();
try testing.expectEqual(EventKind.function_call_arguments_delta, d.kind);
- try testing.expectEqualStrings("fc_1", d.item_id.?);
try testing.expectEqualStrings("{\"x\":1}", d.delta.?);
var alt = try parseStreamEvent(allocator,
@@ -835,7 +812,6 @@ test "responses parseStreamEvent - completed output function calls" {
try testing.expectEqual(EventKind.completed, ev.kind);
try testing.expectEqual(@as(usize, 1), ev.completed_items.len);
try testing.expectEqual(@as(usize, 0), ev.completed_items[0].output_index);
- try testing.expectEqualStrings("fc_1", ev.completed_items[0].item_id.?);
try testing.expectEqualStrings("call_9", ev.completed_items[0].call_id.?);
try testing.expectEqualStrings("std__read", ev.completed_items[0].name.?);
try testing.expectEqualStrings("{\"path\":\"a\"}", ev.completed_items[0].arguments.?);
diff --git a/libpanto/src/pricing.zig b/libpanto/src/pricing.zig
index 97dfe38..0565593 100644
--- a/libpanto/src/pricing.zig
+++ b/libpanto/src/pricing.zig
@@ -83,13 +83,6 @@ pub const Pricing = struct {
if (r >= @as(f64, @floatFromInt(std.math.maxInt(u64)))) return std.math.maxInt(u64);
return @intFromFloat(r);
}
-
- /// Inverse of `fromDollarsPerMtok`: convert the integer representation
- /// back to USD per million tokens (the human-friendly unit). Returns
- /// 0.0 for the null case.
- pub fn toDollarsPerMtok(micro_cents_per_token: u64) f64 {
- return @as(f64, @floatFromInt(micro_cents_per_token)) / 100.0;
- }
};
// =============================================================================
diff --git a/libpanto/src/provider.zig b/libpanto/src/provider.zig
index 3c3b52e..e12ff59 100644
--- a/libpanto/src/provider.zig
+++ b/libpanto/src/provider.zig
@@ -1,4 +1,6 @@
const std = @import("std");
+const http = std.http;
+const Uri = std.Uri;
const config_mod = @import("config.zig");
const conversation = @import("conversation.zig");
@@ -10,6 +12,109 @@ pub const Usage = session_mod.Usage;
const EventQueue = stream_mod.EventQueue;
+/// Open a streaming `POST` to `uri` carrying `body`, with the shared transport
+/// options every provider uses (identity encoding so gzip can't buffer SSE
+/// frames, no keep-alive, no redirects). `req` must point at pinned storage —
+/// the body writer and `receiveHead` borrow it, and the caller keeps it for
+/// the response's lifetime. On success the request is left open (the caller
+/// owns it) and the received response head is returned; on failure the request
+/// is cleaned up before returning the error.
+pub fn sendRequest(
+ client: *http.Client,
+ uri: Uri,
+ extra_headers: []const http.Header,
+ body: []const u8,
+ req: *http.Client.Request,
+) !http.Client.Response {
+ req.* = try client.request(.POST, uri, .{
+ .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" } },
+ .keep_alive = false,
+ .redirect_behavior = .not_allowed,
+ });
+ errdefer req.deinit();
+
+ req.transfer_encoding = .{ .content_length = body.len };
+ var send_buf: [4096]u8 = undefined;
+ var bw = try req.sendBodyUnflushed(&send_buf);
+ try bw.writer.writeAll(body);
+ try bw.end();
+ try req.connection.?.flush();
+
+ var redirect_buf: [1024]u8 = undefined;
+ return try req.receiveHead(&redirect_buf);
+}
+
+/// Handle a >=400 provider response: capture `Retry-After`, drain the body
+/// (capped at 16 KiB) for diagnostics, classify the status, log it (demoting
+/// recoverable auth failures to `.debug`), stash status + retry into `diag`,
+/// and return the classified error for the caller to propagate. `transfer_buf`
+/// backs the drain reader; `name` is the provider's log prefix.
+pub fn classifyErrorResponse(
+ allocator: std.mem.Allocator,
+ response: *http.Client.Response,
+ transfer_buf: []u8,
+ diag: ?*ProviderDiagnostic,
+ name: []const u8,
+) ProviderError {
+ // `head.bytes` (which `iterateHeaders` walks) points into the connection
+ // read buffer and is invalidated the moment the body stream is
+ // initialized below. Capture Retry-After first.
+ const retry_after_ms = retryAfterFromHead(response.head);
+ const body_reader = response.reader(transfer_buf);
+ var err_buf: std.ArrayList(u8) = .empty;
+ defer err_buf.deinit(allocator);
+ var tmp: [1024]u8 = undefined;
+ while (true) {
+ const n = body_reader.readSliceShort(&tmp) catch break;
+ if (n == 0) break;
+ err_buf.appendSlice(allocator, tmp[0..n]) catch break;
+ if (err_buf.items.len > 16 * 1024) break;
+ }
+ const status: u16 = @intFromEnum(response.head.status);
+ const classified = classifyHttpStatus(status, err_buf.items);
+ // 401/403 is routinely recovered by the turn-runner's forced token
+ // refresh + reopen; demote it to `.debug` (still in the debug log) so a
+ // transparent refresh doesn't surface a scary error line. The retry layer
+ // raises a hard error only if recovery ultimately fails.
+ if (classified == error.ProviderAuthFailed) {
+ std.log.debug("{s} HTTP {d} (recoverable auth): {s}", .{ name, status, err_buf.items });
+ } else {
+ std.log.err("{s} HTTP {d}: {s}", .{ name, status, err_buf.items });
+ }
+ if (diag) |d| {
+ d.status_code = status;
+ d.retry_after_ms = retry_after_ms;
+ }
+ return classified;
+}
+
+/// Decode a wire tool name (`__` -> `.`) in place within an assembled name
+/// buffer. Decoding only ever shrinks the buffer (reads stay ahead of writes),
+/// so aliasing src/dst is safe; we then truncate to the decoded length.
+/// Unambiguous because internal names never contain a literal `__`.
+pub fn decodeNameInPlace(name_buf: *conversation.TextualBlock) void {
+ const decoded = tool_registry_mod.decodeName(name_buf.items, name_buf.items);
+ name_buf.items.len = decoded.len;
+}
+
+/// Combine a stream error's `kind` and `message` into one owned, human-readable
+/// string (either may be absent). Returns null when both are absent. Caller
+/// owns the result.
+pub fn formatStreamError(
+ allocator: std.mem.Allocator,
+ kind: ?[]const u8,
+ message: ?[]const u8,
+) std.mem.Allocator.Error!?[]u8 {
+ if (kind != null and message != null)
+ return try std.fmt.allocPrint(allocator, "{s}: {s}", .{ kind.?, message.? });
+ if (kind) |k| return try allocator.dupe(u8, k);
+ if (message) |m| return try allocator.dupe(u8, m);
+ return null;
+}
+
pub const ContentBlockType = enum {
Text,
Thinking,
@@ -44,6 +149,22 @@ pub fn mergeHeaders(
return out;
}
+/// Splice a pre-encoded JSON value into the current stringifier position.
+/// Used to embed a tool's `input_schema` (and a replayed tool_use `input`)
+/// verbatim into a request body. On parse failure, emit `{}` so we never
+/// produce invalid wire JSON — an empty object is the correct degenerate
+/// value on the wire for both uses.
+pub fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void {
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+ defer arena.deinit();
+ const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), raw, .{}) catch {
+ try s.beginObject();
+ try s.endObject();
+ return;
+ };
+ try s.write(parsed.value);
+}
+
pub fn isContextOverflowBody(body: []const u8) bool {
const markers = [_][]const u8{
"context_length_exceeded",
diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig
index 258ddbd..955991a 100644
--- a/libpanto/src/provider_anthropic_messages.zig
+++ b/libpanto/src/provider_anthropic_messages.zig
@@ -135,66 +135,18 @@ pub const AnthropicMessagesRequest = struct {
);
defer self.allocator.free(extra_headers);
- rr.req = try self.http_client.request(.POST, uri, .{
- .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" } },
- .keep_alive = false,
- .redirect_behavior = .not_allowed,
- });
+ rr.response = try provider_mod.sendRequest(self.http_client, uri, extra_headers, body, &rr.req);
rr.req_open = true;
errdefer {
rr.req.deinit();
rr.req_open = false;
}
- rr.req.transfer_encoding = .{ .content_length = body.len };
-
- var send_buf: [4096]u8 = undefined;
- var bw = try rr.req.sendBodyUnflushed(&send_buf);
- try bw.writer.writeAll(body);
- try bw.end();
- try rr.req.connection.?.flush();
-
- var redirect_buf: [1024]u8 = undefined;
- rr.response = try rr.req.receiveHead(&redirect_buf);
-
+ // A >=400 status maps to a retryable/terminal provider error. Anthropic
+ // rejects oversized requests with HTTP 400 + "prompt is too long",
+ // which `classifyErrorResponse` maps to ContextOverflow (compact+retry).
if (@intFromEnum(rr.response.head.status) >= 400) {
- // `head.bytes` (which `iterateHeaders` walks) points into the
- // connection read buffer and is invalidated the moment the body
- // stream is initialized below. Capture Retry-After first.
- const retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
- const body_reader = rr.response.reader(&rr.transfer_buf);
- var err_buf: std.ArrayList(u8) = .empty;
- defer err_buf.deinit(self.allocator);
- var tmp: [1024]u8 = undefined;
- while (true) {
- const n = body_reader.readSliceShort(&tmp) catch break;
- if (n == 0) break;
- try err_buf.appendSlice(self.allocator, tmp[0..n]);
- if (err_buf.items.len > 16 * 1024) break;
- }
- const status: u16 = @intFromEnum(rr.response.head.status);
- // Anthropic rejects oversized requests with HTTP 400 and a
- // "prompt is too long" message; `classifyHttpStatus` maps that to
- // ContextOverflow so the caller can compact and retry. Other
- // statuses map to retryable/terminal provider errors.
- const classified = provider_mod.classifyHttpStatus(status, err_buf.items);
- // 401/403 is routinely recovered by the turn-runner's forced token
- // refresh + reopen; demote it to `.debug` (still in the debug log)
- // so a transparent refresh doesn't surface a scary error line. The
- // retry layer raises a hard error only if recovery ultimately fails.
- if (classified == error.ProviderAuthFailed) {
- std.log.debug("anthropic_messages HTTP {d} (recoverable auth): {s}", .{ status, err_buf.items });
- } else {
- std.log.err("anthropic_messages HTTP {d}: {s}", .{ status, err_buf.items });
- }
- if (self.diag) |d| {
- d.status_code = status;
- d.retry_after_ms = retry_after_ms;
- }
- return classified;
+ return provider_mod.classifyErrorResponse(self.allocator, &rr.response, &rr.transfer_buf, self.diag, "anthropic_messages");
}
rr.body_reader = rr.response.reader(&rr.transfer_buf);
@@ -498,15 +450,7 @@ const StreamState = struct {
/// absent). Replaces any previous value.
fn setStreamErrorMessage(self: *StreamState, kind: ?[]const u8, message: ?[]const u8) !void {
if (self.stream_error_message) |old| self.allocator.free(old);
- self.stream_error_message = null;
- self.stream_error_message = if (kind != null and message != null)
- try std.fmt.allocPrint(self.allocator, "{s}: {s}", .{ kind.?, message.? })
- else if (kind) |k|
- try self.allocator.dupe(u8, k)
- else if (message) |m|
- try self.allocator.dupe(u8, m)
- else
- null;
+ self.stream_error_message = try provider_mod.formatStreamError(self.allocator, kind, message);
}
/// Close the active block: append it to `blocks` and emit block_complete.
diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig
index 3f01e7f..f653ff6 100644
--- a/libpanto/src/provider_openai_chat.zig
+++ b/libpanto/src/provider_openai_chat.zig
@@ -22,20 +22,11 @@ const stream_mod = @import("stream.zig");
const sse_mod = @import("sse.zig");
const json_mod = @import("openai_chat_json.zig");
const config_mod = @import("config.zig");
-const tool_registry_mod = @import("tool_registry.zig");
const Event = stream_mod.Event;
const EventQueue = stream_mod.EventQueue;
-/// Decode a wire tool name (`__` -> `.`) in place within an assembled
-/// name buffer. Decoding only ever shrinks the buffer (reads stay ahead
-/// of writes), so aliasing src/dst is safe; we then truncate to the
-/// decoded length. Unambiguous because internal names never contain a
-/// literal `__` (enforced at registration).
-fn decodeNameInPlace(name_buf: *conversation.TextualBlock) void {
- const decoded = tool_registry_mod.decodeName(name_buf.items, name_buf.items);
- name_buf.items.len = decoded.len;
-}
+const decodeNameInPlace = provider_mod.decodeNameInPlace;
/// Active streaming block type tracked by the state machine. Mirrors the
/// `ContentBlock` union variants but adds `.none` for "no block open yet".
@@ -123,67 +114,18 @@ pub const OpenAIChatRequest = struct {
// response; we want to stream the body as it arrives. The request
// is moved into the heap struct so the body reader (which borrows
// `&rr.response`) stays valid across `produce` calls.
- rr.req = try self.http_client.request(.POST, uri, .{
- .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" } },
- .keep_alive = false,
- .redirect_behavior = .not_allowed,
- });
+ rr.response = try provider_mod.sendRequest(self.http_client, uri, extra_headers, body, &rr.req);
rr.req_open = true;
errdefer {
rr.req.deinit();
rr.req_open = false;
}
- rr.req.transfer_encoding = .{ .content_length = body.len };
-
- var send_buf: [4096]u8 = undefined;
- var bw = try rr.req.sendBodyUnflushed(&send_buf);
- try bw.writer.writeAll(body);
- try bw.end();
- try rr.req.connection.?.flush();
-
- // Receive response headers.
- var redirect_buf: [1024]u8 = undefined;
- rr.response = try rr.req.receiveHead(&redirect_buf);
-
+ // A >=400 status classifies into a retryable/terminal provider error.
+ // HTTP 400 with a context marker becomes `ContextOverflow` so the
+ // caller can compact and retry rather than hard-fail.
if (@intFromEnum(rr.response.head.status) >= 400) {
- // `head.bytes` (which `iterateHeaders` walks) points into the
- // connection read buffer and is invalidated the moment the body
- // stream is initialized below. Capture Retry-After first.
- const retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
- // Drain body for diagnostics.
- const body_reader = rr.response.reader(&rr.transfer_buf);
- var err_buf: std.ArrayList(u8) = .empty;
- defer err_buf.deinit(self.allocator);
- var tmp: [1024]u8 = undefined;
- while (true) {
- const n = body_reader.readSliceShort(&tmp) catch break;
- if (n == 0) break;
- try err_buf.appendSlice(self.allocator, tmp[0..n]);
- if (err_buf.items.len > 16 * 1024) break;
- }
- const status: u16 = @intFromEnum(rr.response.head.status);
- // Classify the status into a retryable/terminal provider error.
- // HTTP 400 with a context marker becomes `ContextOverflow` so the
- // caller can compact and retry rather than hard-fail.
- const classified = provider_mod.classifyHttpStatus(status, err_buf.items);
- // 401/403 is routinely recovered by the turn-runner's forced token
- // refresh + reopen; demote it to `.debug` (still in the debug log)
- // so a transparent refresh doesn't surface a scary error line. The
- // retry layer raises a hard error only if recovery ultimately fails.
- if (classified == error.ProviderAuthFailed) {
- std.log.debug("openai_chat HTTP {d} (recoverable auth): {s}", .{ status, err_buf.items });
- } else {
- std.log.err("openai_chat HTTP {d}: {s}", .{ status, err_buf.items });
- }
- if (self.diag) |d| {
- d.status_code = status;
- d.retry_after_ms = retry_after_ms;
- }
- return classified;
+ return provider_mod.classifyErrorResponse(self.allocator, &rr.response, &rr.transfer_buf, self.diag, "openai_chat");
}
// Bind the streaming body reader. Valid for the lifetime of `rr`
@@ -422,15 +364,7 @@ const StreamState = struct {
/// absent). Replaces any previous value.
fn setStreamErrorMessage(self: *StreamState, kind: ?[]const u8, message: ?[]const u8) !void {
if (self.stream_error_message) |old| self.allocator.free(old);
- self.stream_error_message = null;
- self.stream_error_message = if (kind != null and message != null)
- try std.fmt.allocPrint(self.allocator, "{s}: {s}", .{ kind.?, message.? })
- else if (kind) |k|
- try self.allocator.dupe(u8, k)
- else if (message) |m|
- try self.allocator.dupe(u8, m)
- else
- null;
+ self.stream_error_message = try provider_mod.formatStreamError(self.allocator, kind, message);
}
/// Close the active text/thinking block (if any) and emit
diff --git a/libpanto/src/provider_openai_responses.zig b/libpanto/src/provider_openai_responses.zig
index c172f6d..d764e6f 100644
--- a/libpanto/src/provider_openai_responses.zig
+++ b/libpanto/src/provider_openai_responses.zig
@@ -31,7 +31,6 @@ const stream_mod = @import("stream.zig");
const sse_mod = @import("sse.zig");
const json_mod = @import("openai_responses_json.zig");
const config_mod = @import("config.zig");
-const tool_registry_mod = @import("tool_registry.zig");
const Event = stream_mod.Event;
const EventQueue = stream_mod.EventQueue;
@@ -41,10 +40,7 @@ pub const OpenAIResponsesDialect = enum {
codex,
};
-fn decodeNameInPlace(name_buf: *conversation.TextualBlock) void {
- const decoded = tool_registry_mod.decodeName(name_buf.items, name_buf.items);
- name_buf.items.len = decoded.len;
-}
+const decodeNameInPlace = provider_mod.decodeNameInPlace;
pub const OpenAIResponsesRequest = struct {
allocator: Allocator,
@@ -104,58 +100,15 @@ pub const OpenAIResponsesRequest = struct {
);
defer self.allocator.free(extra_headers);
- rr.req = try self.http_client.request(.POST, uri, .{
- .extra_headers = extra_headers,
- .headers = .{ .accept_encoding = .{ .override = "identity" } },
- .keep_alive = false,
- .redirect_behavior = .not_allowed,
- });
+ rr.response = try provider_mod.sendRequest(self.http_client, uri, extra_headers, body, &rr.req);
rr.req_open = true;
errdefer {
rr.req.deinit();
rr.req_open = false;
}
- rr.req.transfer_encoding = .{ .content_length = body.len };
- var send_buf: [4096]u8 = undefined;
- var bw = try rr.req.sendBodyUnflushed(&send_buf);
- try bw.writer.writeAll(body);
- try bw.end();
- try rr.req.connection.?.flush();
-
- var redirect_buf: [1024]u8 = undefined;
- rr.response = try rr.req.receiveHead(&redirect_buf);
-
if (@intFromEnum(rr.response.head.status) >= 400) {
- const retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
- const body_reader = rr.response.reader(&rr.transfer_buf);
- var err_buf: std.ArrayList(u8) = .empty;
- defer err_buf.deinit(self.allocator);
- var tmp: [1024]u8 = undefined;
- while (true) {
- const n = body_reader.readSliceShort(&tmp) catch break;
- if (n == 0) break;
- try err_buf.appendSlice(self.allocator, tmp[0..n]);
- if (err_buf.items.len > 16 * 1024) break;
- }
- const status: u16 = @intFromEnum(rr.response.head.status);
- const classified = provider_mod.classifyHttpStatus(status, err_buf.items);
- // 401/403 is routinely recovered by the turn-runner's forced token
- // refresh + reopen (see `driveTurn` in tui_app.zig). Logging it at
- // `.err` surfaces a scary "token expired" line for what is a
- // transparent, recoverable refresh. Demote it to `.debug` (still
- // captured in the debug log); the retry layer raises a hard error
- // only if recovery ultimately fails.
- if (classified == error.ProviderAuthFailed) {
- std.log.debug("openai_responses HTTP {d} (recoverable auth): {s}", .{ status, err_buf.items });
- } else {
- std.log.err("openai_responses HTTP {d}: {s}", .{ status, err_buf.items });
- }
- if (self.diag) |d| {
- d.status_code = status;
- d.retry_after_ms = retry_after_ms;
- }
- return classified;
+ return provider_mod.classifyErrorResponse(self.allocator, &rr.response, &rr.transfer_buf, self.diag, "openai_responses");
}
rr.body_reader = rr.response.reader(&rr.transfer_buf);
diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig
index 843989a..35fd09b 100644
--- a/libpanto/src/session.zig
+++ b/libpanto/src/session.zig
@@ -15,8 +15,8 @@
//! is recorded in entries but does NOT round-trip into the in-memory
//! conversation, because providers don't need it for request serialization.
//!
-//! Format version: 1 (see `CURRENT_VERSION`). Migrations live in
-//! `session_manager.zig`.
+//! Format version: 1 (see `CURRENT_VERSION`). No prior versions exist;
+//! when v2 lands, a migration step on load can transform v1 entries.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -73,8 +73,8 @@ pub const WireStamp = struct {
};
/// Bumped whenever the on-disk format changes in a way that older readers
-/// cannot tolerate. Older files are upgraded by `migrate()` on load and
-/// the file is rewritten once.
+/// cannot tolerate. When that happens, add a load-time migration that
+/// upgrades older files and rewrites them once.
pub const CURRENT_VERSION: u32 = 1;
// =============================================================================
diff --git a/scripts/analyze_pi_sessions.py b/scripts/analyze_pi_sessions.py
deleted file mode 100644
index ffa9f00..0000000
--- a/scripts/analyze_pi_sessions.py
+++ /dev/null
@@ -1,458 +0,0 @@
-#!/usr/bin/env python3
-
-import argparse
-import json
-import math
-from collections import Counter
-from pathlib import Path
-from statistics import mean, median
-from typing import Any
-
-
-BUCKETS = ("input", "output", "cache_read", "cache_write")
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(
- description="Analyze Pi agent session logs for token mix and cache behavior."
- )
- parser.add_argument(
- "root",
- nargs="?",
- default="~/.pi/agent/sessions",
- help="Directory containing Pi session .jsonl files.",
- )
- parser.add_argument(
- "--json-out",
- help="Optional path to write the full report as JSON.",
- )
- return parser.parse_args()
-
-
-def usage_value(usage: dict[str, Any], *keys: str) -> int:
- for key in keys:
- value = usage.get(key)
- if value is None:
- continue
- if isinstance(value, bool):
- return int(value)
- if isinstance(value, (int, float)):
- return int(value)
- return 0
-
-
-def safe_div(numerator: float, denominator: float) -> float | None:
- if denominator == 0:
- return None
- return numerator / denominator
-
-
-def percentile(values: list[float], p: float) -> float | None:
- if not values:
- return None
- if len(values) == 1:
- return values[0]
- ordered = sorted(values)
- rank = (len(ordered) - 1) * p
- low = math.floor(rank)
- high = math.ceil(rank)
- if low == high:
- return ordered[low]
- weight = rank - low
- return ordered[low] * (1 - weight) + ordered[high] * weight
-
-
-def summarize(values: list[float]) -> dict[str, float | None]:
- return {
- "count": len(values),
- "mean": mean(values) if values else None,
- "median": median(values) if values else None,
- "p10": percentile(values, 0.10),
- "p90": percentile(values, 0.90),
- "min": min(values) if values else None,
- "max": max(values) if values else None,
- }
-
-
-def session_primary(counter: Counter[str]) -> str | None:
- if not counter:
- return None
- return max(counter.items(), key=lambda item: (item[1], item[0]))[0]
-
-
-def format_number(value: float | None, digits: int = 3) -> str:
- if value is None:
- return "n/a"
- return f"{value:,.{digits}f}"
-
-
-def format_pct(value: float | None, digits: int = 1) -> str:
- if value is None:
- return "n/a"
- return f"{value * 100:.{digits}f}%"
-
-
-def analyze_session(path: Path) -> dict[str, Any]:
- totals = {bucket: 0 for bucket in BUCKETS}
- usage_messages = 0
- assistant_messages = 0
- models = Counter()
- providers = Counter()
- apis = Counter()
- line_count = 0
- parse_errors = 0
-
- with path.open("r", encoding="utf-8") as handle:
- for raw_line in handle:
- line_count += 1
- line = raw_line.strip()
- if not line:
- continue
- try:
- entry = json.loads(line)
- except json.JSONDecodeError:
- parse_errors += 1
- continue
-
- message = entry.get("message")
- if not isinstance(message, dict):
- continue
-
- role = message.get("role")
- if role == "assistant":
- assistant_messages += 1
-
- usage = entry.get("usage") or message.get("usage")
- if role != "assistant" or not isinstance(usage, dict):
- continue
-
- usage_messages += 1
- bucket_values = {
- "input": usage_value(usage, "input", "input_tokens"),
- "output": usage_value(usage, "output", "output_tokens"),
- "cache_read": usage_value(
- usage,
- "cacheRead",
- "cache_read",
- "cache_read_input_tokens",
- ),
- "cache_write": usage_value(
- usage,
- "cacheWrite",
- "cache_write",
- "cache_creation_input_tokens",
- ),
- }
- for bucket, value in bucket_values.items():
- totals[bucket] += value
-
- billable = sum(bucket_values.values())
- if billable <= 0:
- continue
-
- model = (
- entry.get("responseModel")
- or entry.get("model")
- or message.get("responseModel")
- or message.get("model")
- )
- provider = entry.get("provider") or message.get("provider")
- api = entry.get("api") or message.get("api")
- if isinstance(model, str):
- models[model] += billable
- if isinstance(provider, str):
- providers[provider] += billable
- if isinstance(api, str):
- apis[api] += billable
-
- prompt_tokens = totals["input"] + totals["cache_read"] + totals["cache_write"]
- billable_tokens = prompt_tokens + totals["output"]
- cache_activity = totals["cache_read"] + totals["cache_write"]
-
- shares = {
- bucket: safe_div(totals[bucket], billable_tokens) for bucket in BUCKETS
- }
-
- return {
- "path": str(path),
- "lines": line_count,
- "parse_errors": parse_errors,
- "assistant_messages": assistant_messages,
- "usage_messages": usage_messages,
- "totals": totals,
- "prompt_tokens": prompt_tokens,
- "billable_tokens": billable_tokens,
- "cache_activity_tokens": cache_activity,
- "primary_model": session_primary(models),
- "primary_provider": session_primary(providers),
- "primary_api": session_primary(apis),
- "metrics": {
- "prompt_to_output_ratio": safe_div(prompt_tokens, totals["output"]),
- "input_to_output_ratio": safe_div(totals["input"], totals["output"]),
- "cache_hit_rate": safe_div(totals["cache_read"], cache_activity),
- "cache_coverage": safe_div(cache_activity, prompt_tokens),
- "prompt_reuse_rate": safe_div(totals["cache_read"], prompt_tokens),
- "output_share": safe_div(totals["output"], billable_tokens),
- "prompt_share": safe_div(prompt_tokens, billable_tokens),
- **{f"{bucket}_share": value for bucket, value in shares.items()},
- },
- }
-
-
-def build_report(root: Path) -> dict[str, Any]:
- session_files = sorted(root.rglob("*.jsonl"))
- sessions = [analyze_session(path) for path in session_files]
- analyzed_sessions = [s for s in sessions if s["billable_tokens"] > 0]
-
- pooled_totals = {bucket: 0 for bucket in BUCKETS}
- total_prompt = 0
- total_billable = 0
- total_usage_messages = 0
- total_assistant_messages = 0
- total_parse_errors = 0
- provider_sessions = Counter()
- model_sessions = Counter()
-
- per_session_metric_values: dict[str, list[float]] = {}
- cache_active_metric_values: dict[str, list[float]] = {}
- cache_active_sessions = 0
-
- for session in analyzed_sessions:
- for bucket in BUCKETS:
- pooled_totals[bucket] += session["totals"][bucket]
- total_prompt += session["prompt_tokens"]
- total_billable += session["billable_tokens"]
- total_usage_messages += session["usage_messages"]
- total_assistant_messages += session["assistant_messages"]
- total_parse_errors += session["parse_errors"]
- if session["primary_provider"]:
- provider_sessions[session["primary_provider"]] += 1
- if session["primary_model"]:
- model_sessions[session["primary_model"]] += 1
- for name, value in session["metrics"].items():
- if value is None:
- continue
- per_session_metric_values.setdefault(name, []).append(value)
- if session["cache_activity_tokens"] > 0 and name in {
- "cache_hit_rate",
- "cache_coverage",
- "prompt_reuse_rate",
- "cache_read_share",
- "cache_write_share",
- }:
- cache_active_metric_values.setdefault(name, []).append(value)
- per_session_metric_values.setdefault("billable_tokens", []).append(
- session["billable_tokens"]
- )
- per_session_metric_values.setdefault("prompt_tokens", []).append(
- session["prompt_tokens"]
- )
- per_session_metric_values.setdefault("output_tokens", []).append(
- session["totals"]["output"]
- )
- if session["cache_activity_tokens"] > 0:
- cache_active_sessions += 1
-
- pooled_cache_activity = pooled_totals["cache_read"] + pooled_totals["cache_write"]
- pooled_weights = {
- bucket: safe_div(pooled_totals[bucket], total_billable) for bucket in BUCKETS
- }
-
- mean_session_weights = {
- bucket: mean(per_session_metric_values.get(f"{bucket}_share", []))
- if per_session_metric_values.get(f"{bucket}_share")
- else None
- for bucket in BUCKETS
- }
- median_session_weights = {
- bucket: median(per_session_metric_values.get(f"{bucket}_share", []))
- if per_session_metric_values.get(f"{bucket}_share")
- else None
- for bucket in BUCKETS
- }
-
- return {
- "root": str(root),
- "sessions_scanned": len(session_files),
- "sessions_with_usage": len(analyzed_sessions),
- "sessions_without_usage": len(session_files) - len(analyzed_sessions),
- "assistant_messages": total_assistant_messages,
- "assistant_messages_with_usage": total_usage_messages,
- "parse_errors": total_parse_errors,
- "cache_active_sessions": cache_active_sessions,
- "pooled_totals": pooled_totals,
- "pooled_metrics": {
- "prompt_tokens": total_prompt,
- "billable_tokens": total_billable,
- "prompt_to_output_ratio": safe_div(total_prompt, pooled_totals["output"]),
- "input_to_output_ratio": safe_div(
- pooled_totals["input"], pooled_totals["output"]
- ),
- "cache_hit_rate": safe_div(
- pooled_totals["cache_read"], pooled_cache_activity
- ),
- "cache_coverage": safe_div(pooled_cache_activity, total_prompt),
- "prompt_reuse_rate": safe_div(pooled_totals["cache_read"], total_prompt),
- "output_share": safe_div(pooled_totals["output"], total_billable),
- "prompt_share": safe_div(total_prompt, total_billable),
- **{f"{bucket}_share": value for bucket, value in pooled_weights.items()},
- },
- "recommended_weights": {
- "pooled_token_mix": pooled_weights,
- "mean_session_mix": mean_session_weights,
- "median_session_mix": median_session_weights,
- },
- "per_session_distributions": {
- name: summarize(values)
- for name, values in sorted(per_session_metric_values.items())
- },
- "cache_active_session_distributions": {
- name: summarize(values)
- for name, values in sorted(cache_active_metric_values.items())
- },
- "top_primary_providers": provider_sessions.most_common(10),
- "top_primary_models": model_sessions.most_common(10),
- }
-
-
-def print_report(report: dict[str, Any]) -> None:
- print("Pi Session Cost Analysis")
- print("========================")
- print(f"Root: {report['root']}")
- print(
- f"Sessions scanned: {report['sessions_scanned']} "
- f"({report['sessions_with_usage']} with usage)"
- )
- print(
- f"Assistant messages with usage: {report['assistant_messages_with_usage']} "
- f"of {report['assistant_messages']}"
- )
- print(f"Cache-active sessions: {report['cache_active_sessions']}")
- print()
-
- pooled = report["pooled_metrics"]
- totals = report["pooled_totals"]
- weights = report["recommended_weights"]
-
- print("Pooled Totals")
- print("-------------")
- print(f"Input: {totals['input']:,}")
- print(f"Output: {totals['output']:,}")
- print(f"Cache read: {totals['cache_read']:,}")
- print(f"Cache write: {totals['cache_write']:,}")
- print(f"Billable: {pooled['billable_tokens']:,}")
- print()
-
- print("Recommended Weight Vectors")
- print("--------------------------")
- for label, vector in (
- ("Pooled token mix", weights["pooled_token_mix"]),
- ("Mean session mix", weights["mean_session_mix"]),
- ("Median session mix", weights["median_session_mix"]),
- ):
- print(
- f"{label:18} "
- f"input={format_pct(vector['input'])} "
- f"output={format_pct(vector['output'])} "
- f"cache_read={format_pct(vector['cache_read'])} "
- f"cache_write={format_pct(vector['cache_write'])}"
- )
- print()
-
- print("Key Corpus Metrics")
- print("------------------")
- print(
- f"Prompt/output ratio: {format_number(pooled['prompt_to_output_ratio'])}x "
- f"(prompt = input + cache_read + cache_write)"
- )
- print(
- f"Uncached input/output ratio: {format_number(pooled['input_to_output_ratio'])}x"
- )
- print(f"Cache hit rate: {format_pct(pooled['cache_hit_rate'])}")
- print(f"Cache coverage of prompt side: {format_pct(pooled['cache_coverage'])}")
- print(f"Prompt reuse rate: {format_pct(pooled['prompt_reuse_rate'])}")
- print()
-
- print("Per-Session Distributions")
- print("-------------------------")
- metric_names = [
- "billable_tokens",
- "prompt_to_output_ratio",
- "input_to_output_ratio",
- "cache_hit_rate",
- "cache_coverage",
- "prompt_reuse_rate",
- "input_share",
- "output_share",
- "cache_read_share",
- "cache_write_share",
- ]
- for name in metric_names:
- stats = report["per_session_distributions"].get(name)
- if not stats:
- continue
- unit = "%" if (
- name.endswith("_share")
- or "rate" in name
- or "coverage" in name
- ) else ""
- value_fmt = format_pct if unit == "%" else format_number
- print(
- f"{name:22} "
- f"mean={value_fmt(stats['mean'])} "
- f"median={value_fmt(stats['median'])} "
- f"p10={value_fmt(stats['p10'])} "
- f"p90={value_fmt(stats['p90'])}"
- )
- print()
-
- if report["cache_active_session_distributions"]:
- print("Cache-Active Session Distributions")
- print("----------------------------------")
- for name in [
- "cache_hit_rate",
- "cache_coverage",
- "prompt_reuse_rate",
- "cache_read_share",
- "cache_write_share",
- ]:
- stats = report["cache_active_session_distributions"].get(name)
- if not stats:
- continue
- print(
- f"{name:22} "
- f"mean={format_pct(stats['mean'])} "
- f"median={format_pct(stats['median'])} "
- f"p10={format_pct(stats['p10'])} "
- f"p90={format_pct(stats['p90'])}"
- )
- print()
-
- print("Top Primary Providers")
- print("---------------------")
- for provider, count in report["top_primary_providers"]:
- print(f"{provider:20} {count}")
- print()
-
- print("Top Primary Models")
- print("------------------")
- for model, count in report["top_primary_models"]:
- print(f"{model:35} {count}")
-
-
-def main() -> int:
- args = parse_args()
- root = Path(args.root).expanduser().resolve()
- report = build_report(root)
-
- if args.json_out:
- out_path = Path(args.json_out).expanduser().resolve()
- out_path.parent.mkdir(parents=True, exist_ok=True)
- out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
-
- print_report(report)
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())