summaryrefslogtreecommitdiff
path: root/libpanto/src/conversation.zig
diff options
context:
space:
mode:
Diffstat (limited to 'libpanto/src/conversation.zig')
-rw-r--r--libpanto/src/conversation.zig59
1 files changed, 59 insertions, 0 deletions
diff --git a/libpanto/src/conversation.zig b/libpanto/src/conversation.zig
index 4338563..2c63f19 100644
--- a/libpanto/src/conversation.zig
+++ b/libpanto/src/conversation.zig
@@ -258,6 +258,16 @@ pub const Message = struct {
/// commits, read back off the `Message` after `load`. Borrowed; owned by
/// whoever set it (the conversation's allocator on the replay path).
metadata: ?[]const u8 = null,
+ /// The wire-format provider identity (`api_style`/`base_url`/`model` and
+ /// the reasoning/thinking knobs) that actually produced this message.
+ /// Stamped once, when the message is first persisted, and from the disk
+ /// stamp on replay. Preserved verbatim through compaction's clone, so a
+ /// kept-verbatim turn keeps the identity of the model that generated it
+ /// rather than being re-stamped with the compaction model. This is the
+ /// per-message source of truth for thinking-signature replay — no need to
+ /// pin the identity onto each thinking block. When set, the `base_url`
+ /// and `model` slices are owned by the conversation's allocator.
+ identity: ?config.WireIdentity = null,
pub fn deinit(self: *Message, alloc: Allocator) void {
for (self.content.items) |*block| {
@@ -265,9 +275,29 @@ pub const Message = struct {
}
self.content.deinit(alloc);
if (self.metadata) |m| alloc.free(m);
+ if (self.identity) |id| freeWireIdentity(alloc, id);
}
};
+/// Duplicate a `WireIdentity`'s owned slices (`base_url`, `model`) into
+/// `alloc`; scalar fields are copied as-is. The result owns its strings and
+/// must be released with `freeWireIdentity`.
+pub fn dupeWireIdentity(alloc: Allocator, id: config.WireIdentity) !config.WireIdentity {
+ const burl = try alloc.dupe(u8, id.base_url);
+ errdefer alloc.free(burl);
+ const mdl = try alloc.dupe(u8, id.model);
+ var out = id;
+ out.base_url = burl;
+ out.model = mdl;
+ return out;
+}
+
+/// Free the owned slices of a `WireIdentity` produced by `dupeWireIdentity`.
+pub fn freeWireIdentity(alloc: Allocator, id: config.WireIdentity) void {
+ alloc.free(id.base_url);
+ alloc.free(id.model);
+}
+
pub const Conversation = struct {
messages: std.ArrayList(Message) = .empty,
allocator: Allocator,
@@ -396,6 +426,35 @@ pub const Conversation = struct {
}
};
+/// Whether a thinking block's opaque signature may be replayed to a request
+/// targeting `(api_style, base_url, model)`. A signature is portable only
+/// back to the exact endpoint/model that produced it; replaying it elsewhere
+/// is rejected (Anthropic) or meaningless (OpenAI encrypted reasoning).
+///
+/// Provenance is resolved per block first (`signature_origin`, set live by
+/// the producing provider), then falls back to the enclosing message's
+/// `identity` (the per-message source of truth that survives compaction). A
+/// block with no signature, or with no provenance from either source, is not
+/// replayable — sending a signature to an unverified endpoint is unsafe.
+pub fn thinkingSignatureMatches(
+ block: ThinkingBlock,
+ msg_identity: ?config.WireIdentity,
+ api_style: config.APIStyle,
+ base_url: []const u8,
+ model: []const u8,
+) bool {
+ if (block.signature == null) return false;
+ if (block.signature_origin) |origin| {
+ return origin.matches(api_style, base_url, model);
+ }
+ if (msg_identity) |id| {
+ return id.api_style == api_style and
+ std.mem.eql(u8, id.base_url, base_url) and
+ std.mem.eql(u8, id.model, model);
+ }
+ return false;
+}
+
pub fn setThinkingOrigins(
allocator: Allocator,
blocks: []ContentBlock,