From 02b4c7a35ac0b714bc045d54fb4bb45d1ce4e490 Mon Sep 17 00:00:00 2001 From: t Date: Sat, 13 Jun 2026 23:45:59 -0600 Subject: 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. --- libpanto/src/openai_responses_json.zig | 319 +++++++++++++++++++++++++++++++-- 1 file changed, 304 insertions(+), 15 deletions(-) (limited to 'libpanto/src/openai_responses_json.zig') 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, -- cgit v1.3