diff options
| -rw-r--r-- | libpanto/src/agent.zig | 7 | ||||
| -rw-r--r-- | libpanto/src/anthropic_messages_json.zig | 49 | ||||
| -rw-r--r-- | libpanto/src/auth.zig | 6 | ||||
| -rw-r--r-- | libpanto/src/compaction.zig | 10 | ||||
| -rw-r--r-- | libpanto/src/conversation.zig | 26 | ||||
| -rw-r--r-- | libpanto/src/file_system_jsonl_store.zig | 205 | ||||
| -rw-r--r-- | libpanto/src/image.zig | 50 | ||||
| -rw-r--r-- | libpanto/src/openai_chat_json.zig | 25 | ||||
| -rw-r--r-- | libpanto/src/openai_responses_json.zig | 30 | ||||
| -rw-r--r-- | libpanto/src/pricing.zig | 7 | ||||
| -rw-r--r-- | libpanto/src/provider.zig | 121 | ||||
| -rw-r--r-- | libpanto/src/provider_anthropic_messages.zig | 68 | ||||
| -rw-r--r-- | libpanto/src/provider_openai_chat.zig | 80 | ||||
| -rw-r--r-- | libpanto/src/provider_openai_responses.zig | 53 | ||||
| -rw-r--r-- | libpanto/src/session.zig | 8 | ||||
| -rw-r--r-- | scripts/analyze_pi_sessions.py | 458 |
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()) |
