summaryrefslogtreecommitdiff
path: root/libpanto/src/openai_responses_json.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 23:45:59 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commit02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 (patch)
tree134196a82dd6fdd55b735be4c0cf38428c85038d /libpanto/src/openai_responses_json.zig
parent71643a5d69ffc40882c9fcde3cc8a3bcf02d7396 (diff)
Add Codex Responses support and session debugging
Teach provider config and auth resolution about the Codex Responses dialect, including user-facing `style = "openai_responses"` with `dialect = "codex"`. Serialize and parse Responses traffic with provider-specific reasoning replay, assistant phase metadata, and robust function-call assembly keyed by `output_index` so streamed tool inputs survive proxy quirks and empty terminal payloads. Also persist thinking origins and message metadata across sessions, add the Anthropic interleaved-thinking header switch, write per-session debug logs, and improve the TUI and scripts for inspecting tool output and session costs.
Diffstat (limited to 'libpanto/src/openai_responses_json.zig')
-rw-r--r--libpanto/src/openai_responses_json.zig319
1 files changed, 304 insertions, 15 deletions
diff --git a/libpanto/src/openai_responses_json.zig b/libpanto/src/openai_responses_json.zig
index 8ff7712..295e79c 100644
--- a/libpanto/src/openai_responses_json.zig
+++ b/libpanto/src/openai_responses_json.zig
@@ -30,6 +30,11 @@ const tool_registry_mod = @import("tool_registry.zig");
// Request serialization
// ===========================================================================
+pub const RequestDialect = enum {
+ public,
+ codex,
+};
+
/// Serialize a Conversation into a `/responses` request body. Caller owns the
/// returned slice.
pub fn serializeRequest(
@@ -37,6 +42,7 @@ pub fn serializeRequest(
cfg: *const config_mod.OpenAIResponsesConfig,
conv: *const conversation.Conversation,
tools: *const tool_registry_mod.ToolRegistry,
+ dialect: RequestDialect,
) ![]u8 {
var aw: Writer.Allocating = .init(allocator);
errdefer aw.deinit();
@@ -54,8 +60,24 @@ pub fn serializeRequest(
try s.objectField("store");
try s.write(false);
- try s.objectField("max_output_tokens");
- try s.write(cfg.max_tokens);
+ if (dialect == .public) {
+ try s.objectField("max_output_tokens");
+ try s.write(cfg.max_tokens);
+ }
+
+ if (dialect == .codex) {
+ try s.objectField("text");
+ try s.beginObject();
+ try s.objectField("verbosity");
+ try s.write("low");
+ try s.endObject();
+
+ try s.objectField("tool_choice");
+ try s.write("auto");
+
+ try s.objectField("parallel_tool_calls");
+ try s.write(true);
+ }
// Carry encrypted reasoning so multi-turn reasoning can continue without
// server-side storage.
@@ -110,7 +132,7 @@ pub fn serializeRequest(
try s.beginArray();
for (conversation.activeMessageWindow(conv.messages.items)) |msg| {
if (msg.role == .system) continue;
- try writeInputForMessage(&s, msg, allocator);
+ try writeInputForMessage(&s, msg, allocator, cfg, dialect);
}
try s.endArray();
@@ -129,7 +151,13 @@ fn writeReasoning(s: *std.json.Stringify, effort: []const u8) !void {
}
/// Emit the `input` item(s) for one conversation message.
-fn writeInputForMessage(s: *std.json.Stringify, msg: conversation.Message, allocator: Allocator) !void {
+fn writeInputForMessage(
+ s: *std.json.Stringify,
+ msg: conversation.Message,
+ allocator: Allocator,
+ cfg: *const config_mod.OpenAIResponsesConfig,
+ dialect: RequestDialect,
+) !void {
switch (msg.role) {
.system => {},
.user => {
@@ -161,17 +189,33 @@ fn writeInputForMessage(s: *std.json.Stringify, msg: conversation.Message, alloc
defer text_buf.deinit(allocator);
try concatTextBlocks(msg.content.items, &text_buf, allocator);
if (text_buf.items.len > 0) {
- try writeRoleMessage(s, "user", "input_text", text_buf.items);
+ try writeRoleMessage(s, "user", "input_text", text_buf.items, null);
}
},
.assistant => {
+ // Replay opaque reasoning items first so stateless follow-up turns
+ // preserve encrypted reasoning continuity.
+ for (msg.content.items) |block| {
+ if (block != .Thinking) continue;
+ const tb = block.Thinking;
+ const sig = tb.signature orelse continue;
+ const origin = tb.signature_origin orelse continue;
+ if (!origin.matches(
+ if (dialect == .codex) .openai_codex_responses else .openai_responses,
+ cfg.base_url,
+ cfg.model,
+ )) continue;
+ if (sig.len == 0 or sig[0] != '{') continue;
+ try writeRawJson(s, sig);
+ }
+
// Assistant text first (as an output_text message), then each
// tool call as a `function_call` item.
var text_buf: std.ArrayList(u8) = .empty;
defer text_buf.deinit(allocator);
try concatTextBlocks(msg.content.items, &text_buf, allocator);
if (text_buf.items.len > 0) {
- try writeRoleMessage(s, "assistant", "output_text", text_buf.items);
+ try writeRoleMessage(s, "assistant", "output_text", text_buf.items, openAIPhaseFromMetadata(msg.metadata));
}
for (msg.content.items) |block| {
if (block != .ToolUse) continue;
@@ -192,10 +236,20 @@ fn writeInputForMessage(s: *std.json.Stringify, msg: conversation.Message, alloc
}
}
-fn writeRoleMessage(s: *std.json.Stringify, role: []const u8, content_type: []const u8, text: []const u8) !void {
+fn writeRoleMessage(
+ s: *std.json.Stringify,
+ role: []const u8,
+ content_type: []const u8,
+ text: []const u8,
+ phase: ?[]const u8,
+) !void {
try s.beginObject();
try s.objectField("role");
try s.write(role);
+ if (phase) |p| {
+ try s.objectField("phase");
+ try s.write(p);
+ }
try s.objectField("content");
try s.beginArray();
try s.beginObject();
@@ -208,6 +262,17 @@ fn writeRoleMessage(s: *std.json.Stringify, role: []const u8, content_type: []co
try s.endObject();
}
+fn openAIPhaseFromMetadata(metadata: ?[]const u8) ?[]const u8 {
+ const md = metadata orelse return null;
+ var parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, md, .{}) catch return null;
+ defer parsed.deinit();
+ if (parsed.value != .object) return null;
+ const phase = strField(parsed.value.object, "openai_responses_phase") orelse return null;
+ if (std.mem.eql(u8, phase, "commentary")) return "commentary";
+ if (std.mem.eql(u8, phase, "final_answer")) return "final_answer";
+ return null;
+}
+
fn concatTextBlocks(
blocks: []const conversation.ContentBlock,
out: *std.ArrayList(u8),
@@ -244,6 +309,7 @@ pub const EventKind = enum {
output_text_delta,
reasoning_summary_delta,
function_call_arguments_delta,
+ function_call_arguments_done,
output_item_done,
completed,
failed,
@@ -261,8 +327,16 @@ pub const StreamEvent = struct {
/// 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_index: ?usize = null,
/// Item type on add/done: "message" | "function_call" | "reasoning".
item_type: ?[]const u8 = null,
+ /// Assistant message phase on message output items.
+ item_phase: ?[]const u8 = null,
+ /// Raw reasoning output item JSON, used for stateless encrypted reasoning
+ /// replay on follow-up turns.
+ reasoning_item_json: ?[]const u8 = null,
/// Function-call identity (on `output_item.added`/`done`).
call_id: ?[]const u8 = null,
name: ?[]const u8 = null,
@@ -272,6 +346,8 @@ pub const StreamEvent = struct {
error_message: ?[]const u8 = null,
/// Usage on `response.completed`.
usage: ?Usage = null,
+ /// Function-call output items included in a terminal `response.completed`.
+ completed_items: []const OutputItem = &.{},
pub const Usage = struct {
input_tokens: u64 = 0,
@@ -280,6 +356,15 @@ pub const StreamEvent = struct {
reasoning_tokens: u64 = 0,
};
+ 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,
+ };
+
pub fn deinit(self: *StreamEvent) void {
self.parsed.deinit();
}
@@ -305,23 +390,36 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !StreamEvent
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");
+ 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;
- readItem(obj, &ev);
+ ev.output_index = usizeField(obj, "output_index");
+ try readItem(parsed.arena.allocator(), obj, &ev);
} else if (std.mem.eql(u8, type_str, "response.output_item.done")) {
ev.kind = .output_item_done;
- readItem(obj, &ev);
- } else if (std.mem.eql(u8, type_str, "response.completed")) {
+ ev.output_index = usizeField(obj, "output_index");
+ try readItem(parsed.arena.allocator(), obj, &ev);
+ } else if (std.mem.eql(u8, type_str, "response.completed") or std.mem.eql(u8, type_str, "response.done")) {
ev.kind = .completed;
readUsage(obj, &ev);
+ try readCompletedOutput(parsed.arena.allocator(), obj, &ev);
} else if (std.mem.eql(u8, type_str, "response.failed") or std.mem.eql(u8, type_str, "response.incomplete")) {
ev.kind = .failed;
ev.error_message = readResponseError(obj);
@@ -334,15 +432,31 @@ pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !StreamEvent
return ev;
}
-fn readItem(obj: std.json.ObjectMap, ev: *StreamEvent) void {
+fn readItem(allocator: Allocator, obj: std.json.ObjectMap, ev: *StreamEvent) !void {
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");
ev.name = strField(io, "name");
ev.arguments = strField(io, "arguments");
+ if (ev.item_type) |it| {
+ if (std.mem.eql(u8, it, "reasoning")) {
+ if (io.get("encrypted_content") != null) {
+ ev.reasoning_item_json = try stringifyValue(allocator, item);
+ }
+ }
+ }
+}
+
+fn stringifyValue(allocator: Allocator, value: std.json.Value) ![]const u8 {
+ var aw: Writer.Allocating = .init(allocator);
+ errdefer aw.deinit();
+ var s: std.json.Stringify = .{ .writer = &aw.writer };
+ try s.write(value);
+ return try aw.toOwnedSlice();
}
fn readUsage(obj: std.json.ObjectMap, ev: *StreamEvent) void {
@@ -366,6 +480,32 @@ fn readUsage(obj: std.json.ObjectMap, ev: *StreamEvent) void {
ev.usage = usage;
}
+fn readCompletedOutput(allocator: Allocator, obj: std.json.ObjectMap, ev: *StreamEvent) !void {
+ const resp = obj.get("response") orelse return;
+ if (resp != .object) return;
+ const output = resp.object.get("output") orelse return;
+ if (output != .array) return;
+
+ var items: std.ArrayList(StreamEvent.OutputItem) = .empty;
+ errdefer items.deinit(allocator);
+ for (output.array.items, 0..) |v, i| {
+ if (v != .object) continue;
+ const item_type = strField(v.object, "type") orelse continue;
+ 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"),
+ });
+ }
+ if (items.items.len == 0) return;
+ const buf = try items.toOwnedSlice(allocator);
+ ev.completed_items = buf;
+}
+
fn readResponseError(obj: std.json.ObjectMap) ?[]const u8 {
const resp = obj.get("response") orelse return null;
if (resp != .object) return null;
@@ -389,6 +529,12 @@ fn u64Field(obj: std.json.ObjectMap, name: []const u8) u64 {
return @intCast(v.integer);
}
+fn usizeField(obj: std.json.ObjectMap, name: []const u8) ?usize {
+ const v = obj.get(name) orelse return null;
+ if (v != .integer or v.integer < 0) return null;
+ return @intCast(v.integer);
+}
+
// ===========================================================================
// Tests
// ===========================================================================
@@ -421,7 +567,7 @@ test "responses serializeRequest - instructions, input, store/include" {
cfg.reasoning = .high;
var tools = emptyTools();
defer tools.deinit();
- const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools, .public);
defer allocator.free(body);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
@@ -458,7 +604,7 @@ test "responses serializeRequest - tools are flat function items" {
});
const cfg = testConfig("gpt-5.1-codex");
- const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools, .public);
defer allocator.free(body);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
defer parsed.deinit();
@@ -471,6 +617,87 @@ test "responses serializeRequest - tools are flat function items" {
try testing.expect(tool0.get("parameters").? == .object);
}
+test "responses serializeRequest - codex dialect omits max tokens and sends codex defaults" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "Hello!");
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-5.5");
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools, .codex);
+ defer allocator.free(body);
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+ const root = parsed.value.object;
+
+ try testing.expect(root.get("max_output_tokens") == null);
+ try testing.expectEqualStrings("low", root.get("text").?.object.get("verbosity").?.string);
+ try testing.expectEqualStrings("auto", root.get("tool_choice").?.string);
+ try testing.expect(root.get("parallel_tool_calls").?.bool);
+}
+
+test "responses serializeRequest - assistant phase metadata and reasoning signature replay" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const sig = try allocator.dupe(u8,
+ "{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"sealed\"}"
+ );
+ const thinking = try conversation.textualBlockFromSlice(allocator, "thinking");
+ const text = try conversation.textualBlockFromSlice(allocator, "answer");
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{ .text = thinking, .signature = sig } },
+ .{ .Text = text },
+ }, null);
+ try conversation.setThinkingOrigins(allocator, conv.messages.items[0].content.items, .openai_codex_responses, "u", "gpt-5.5");
+ conv.messages.items[0].metadata = try allocator.dupe(u8, "{\"openai_responses_phase\":\"final_answer\"}");
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-5.5");
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools, .codex);
+ defer allocator.free(body);
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const input = parsed.value.object.get("input").?.array.items;
+ try testing.expectEqual(@as(usize, 2), input.len);
+ try testing.expectEqualStrings("reasoning", input[0].object.get("type").?.string);
+ try testing.expectEqualStrings("sealed", input[0].object.get("encrypted_content").?.string);
+ try testing.expectEqualStrings("assistant", input[1].object.get("role").?.string);
+ try testing.expectEqualStrings("final_answer", input[1].object.get("phase").?.string);
+}
+
+test "responses serializeRequest - mismatched signature origin skips reasoning replay" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const sig = try allocator.dupe(u8,
+ "{\"type\":\"reasoning\",\"id\":\"rs_1\",\"encrypted_content\":\"sealed\"}"
+ );
+ try conv.addAssistantMessage(&.{
+ .{ .Thinking = .{ .text = try conversation.textualBlockFromSlice(allocator, "thinking"), .signature = sig } },
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "answer") },
+ }, null);
+ try conversation.setThinkingOrigins(allocator, conv.messages.items[0].content.items, .openai_responses, "https://api.individual.githubcopilot.com", "gpt-5.4-mini");
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-5.5");
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools, .codex);
+ defer allocator.free(body);
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
+ defer parsed.deinit();
+
+ const input = parsed.value.object.get("input").?.array.items;
+ try testing.expectEqual(@as(usize, 1), input.len);
+ try testing.expectEqualStrings("assistant", input[0].object.get("role").?.string);
+}
+
test "responses serializeRequest - assistant tool_use + tool result round-trip" {
const allocator = testing.allocator;
var conv = conversation.Conversation.init(allocator);
@@ -495,7 +722,7 @@ test "responses serializeRequest - assistant tool_use + tool result round-trip"
var tools = emptyTools();
defer tools.deinit();
const cfg = testConfig("gpt-5.1-codex");
- const body = try serializeRequest(allocator, &cfg, &conv, &tools);
+ const body = try serializeRequest(allocator, &cfg, &conv, &tools, .public);
defer allocator.free(body);
var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{});
defer parsed.deinit();
@@ -540,6 +767,29 @@ test "responses parseStreamEvent - function_call item added/done + args delta" {
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,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","arguments_delta":"{\"x\":1}"}
+ );
+ defer alt.deinit();
+ try testing.expectEqual(EventKind.function_call_arguments_delta, alt.kind);
+ try testing.expectEqualStrings("{\"x\":1}", alt.delta.?);
+
+ var by_index = try parseStreamEvent(allocator,
+ \\{"type":"response.function_call_arguments.delta","output_index":0,"delta":"{\"x\":1}"}
+ );
+ defer by_index.deinit();
+ try testing.expectEqual(EventKind.function_call_arguments_delta, by_index.kind);
+ try testing.expectEqual(@as(usize, 0), by_index.output_index.?);
+ try testing.expectEqualStrings("{\"x\":1}", by_index.delta.?);
+
+ var done = try parseStreamEvent(allocator,
+ \\{"type":"response.function_call_arguments.done","output_index":0,"arguments":"{\"x\":1}"}
+ );
+ defer done.deinit();
+ try testing.expectEqual(EventKind.function_call_arguments_done, done.kind);
+ try testing.expectEqual(@as(usize, 0), done.output_index.?);
+ try testing.expectEqualStrings("{\"x\":1}", done.arguments.?);
}
test "responses parseStreamEvent - completed usage" {
@@ -555,6 +805,45 @@ test "responses parseStreamEvent - completed usage" {
try testing.expectEqual(@as(u64, 8), ev.usage.?.reasoning_tokens);
}
+test "responses parseStreamEvent - done alias, phase, encrypted reasoning item" {
+ const allocator = testing.allocator;
+ var done = try parseStreamEvent(allocator,
+ \\{"type":"response.done","response":{"usage":{"input_tokens":1,"output_tokens":2}}}
+ );
+ defer done.deinit();
+ try testing.expectEqual(EventKind.completed, done.kind);
+ try testing.expectEqual(@as(u64, 1), done.usage.?.input_tokens);
+
+ var msg = try parseStreamEvent(allocator,
+ \\{"type":"response.output_item.done","item":{"type":"message","id":"msg_1","phase":"commentary"}}
+ );
+ defer msg.deinit();
+ try testing.expectEqual(EventKind.output_item_done, msg.kind);
+ try testing.expectEqualStrings("commentary", msg.item_phase.?);
+
+ var reasoning = try parseStreamEvent(allocator,
+ \\{"type":"response.output_item.done","item":{"type":"reasoning","id":"rs_1","encrypted_content":"sealed"}}
+ );
+ defer reasoning.deinit();
+ try testing.expectEqual(EventKind.output_item_done, reasoning.kind);
+ try testing.expect(reasoning.reasoning_item_json != null);
+}
+
+test "responses parseStreamEvent - completed output function calls" {
+ const allocator = testing.allocator;
+ var ev = try parseStreamEvent(allocator,
+ \\{"type":"response.completed","response":{"output":[{"type":"function_call","id":"fc_1","call_id":"call_9","name":"std__read","arguments":"{\"path\":\"a\"}","status":"completed"}],"usage":{"input_tokens":100,"output_tokens":20}}}
+ );
+ defer ev.deinit();
+ 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.?);
+}
+
test "responses parseStreamEvent - error + failed" {
const allocator = testing.allocator;
var e = try parseStreamEvent(allocator,