summaryrefslogtreecommitdiff
path: root/src/openai_responses_json.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:26:32 -0600
committert <t@tjp.lol>2026-07-07 11:26:45 -0600
commitf83578fdc9264019a1a1cef8c5484a161167d3dd (patch)
tree888f11767f944d61e5ca8eb92fa1b2dba295a4b8 /src/openai_responses_json.zig
initial commit, moved libpanto over from the pantograph repo
Diffstat (limited to 'src/openai_responses_json.zig')
-rw-r--r--src/openai_responses_json.zig941
1 files changed, 941 insertions, 0 deletions
diff --git a/src/openai_responses_json.zig b/src/openai_responses_json.zig
new file mode 100644
index 0000000..358fc45
--- /dev/null
+++ b/src/openai_responses_json.zig
@@ -0,0 +1,941 @@
+//! OpenAI Responses API JSON serialization and streaming-event parsing.
+//!
+//! The Responses API (`POST /responses`) backs the ChatGPT-subscription Codex
+//! provider. Its wire shape differs from Chat Completions:
+//!
+//! - The system prompt rides in a top-level `instructions` string.
+//! - History is an `input` array of items: `{role, content:[{type, text}]}`
+//! messages, `{type:"function_call", call_id, name, arguments}` for
+//! assistant tool calls, and `{type:"function_call_output", call_id,
+//! output}` for tool results.
+//! - Tools are flat: `{type:"function", name, description, parameters}`.
+//! - Reasoning is requested via `{reasoning:{effort, summary}}` and
+//! `include:["reasoning.encrypted_content"]`; `store:false` keeps the
+//! exchange stateless.
+//! - Streaming uses typed SSE events (`response.output_text.delta`,
+//! `response.function_call_arguments.delta`, `response.completed`, …)
+//! rather than Chat Completions' `choices[].delta`.
+//!
+//! References: OpenAI Responses API streaming docs and the open-source Codex
+//! client's request transformer.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+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
+// ===========================================================================
+
+pub const RequestDialect = enum {
+ public,
+ codex,
+};
+
+/// Serialize a Conversation into a `/responses` request body. Caller owns the
+/// returned slice.
+pub fn serializeRequest(
+ allocator: Allocator,
+ 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();
+ var s: std.json.Stringify = .{ .writer = &aw.writer };
+
+ try s.beginObject();
+
+ try s.objectField("model");
+ try s.write(cfg.model);
+
+ try s.objectField("stream");
+ try s.write(true);
+
+ // Stateless: we replay full history each turn (no server-side state).
+ try s.objectField("store");
+ try s.write(false);
+
+ 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.
+ try s.objectField("include");
+ try s.beginArray();
+ try s.write("reasoning.encrypted_content");
+ try s.endArray();
+
+ switch (cfg.reasoning) {
+ .default => {},
+ // The Codex backend does not accept "none"; the lowest real effort is
+ // "low". `.off`/`.minimal` map to "low".
+ .off, .minimal, .low => try writeReasoning(&s, "low"),
+ .medium => try writeReasoning(&s, "medium"),
+ .high => try writeReasoning(&s, "high"),
+ }
+
+ // System prompt → `instructions` (joined with blank lines).
+ var sys_blocks = try conversation.effectiveSystemBlocks(allocator, conv.messages.items);
+ defer sys_blocks.deinit(allocator);
+ if (sys_blocks.items.len > 0) {
+ var instr: std.ArrayList(u8) = .empty;
+ defer instr.deinit(allocator);
+ for (sys_blocks.items, 0..) |text, i| {
+ if (i != 0) try instr.appendSlice(allocator, "\n\n");
+ try instr.appendSlice(allocator, text);
+ }
+ try s.objectField("instructions");
+ try s.write(instr.items);
+ }
+
+ if (tools.count() > 0) {
+ try s.objectField("tools");
+ try s.beginArray();
+ var it = tools.toolsForLLM();
+ while (it.next()) |t| {
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("function");
+ try s.objectField("name");
+ try s.write(t.decl.name); // already wire-encoded
+ try s.objectField("description");
+ try s.write(t.decl.description);
+ try s.objectField("parameters");
+ try writeRawJson(&s, t.decl.schema_json);
+ try s.endObject();
+ }
+ try s.endArray();
+ }
+
+ try s.objectField("input");
+ try s.beginArray();
+ for (conversation.activeMessageWindow(conv.messages.items)) |msg| {
+ if (msg.role == .system) continue;
+ try writeInputForMessage(&s, msg, allocator, cfg, dialect);
+ }
+ try s.endArray();
+
+ try s.endObject();
+ return try aw.toOwnedSlice();
+}
+
+fn writeReasoning(s: *std.json.Stringify, effort: []const u8) !void {
+ try s.objectField("reasoning");
+ try s.beginObject();
+ try s.objectField("effort");
+ try s.write(effort);
+ try s.objectField("summary");
+ try s.write("auto");
+ try s.endObject();
+}
+
+/// Emit the `input` item(s) for one conversation message.
+fn writeInputForMessage(
+ s: *std.json.Stringify,
+ msg: conversation.Message,
+ allocator: Allocator,
+ cfg: *const config_mod.OpenAIResponsesConfig,
+ dialect: RequestDialect,
+) !void {
+ switch (msg.role) {
+ .system => {},
+ .user => {
+ // Tool results fan out into `function_call_output` items; any
+ // plain text becomes a `user` message.
+ var has_tool_result = false;
+ for (msg.content.items) |b| {
+ if (b == .ToolResult) has_tool_result = true;
+ }
+ if (has_tool_result) {
+ // `function_call_output` is text-only. Each result carries
+ // its text (plus a short note when media is present); the
+ // media rides along afterward in a synthetic user message.
+ var any_media = false;
+ for (msg.content.items) |block| {
+ if (block != .ToolResult) continue;
+ const tr = block.ToolResult;
+ if (tr.hasMedia()) any_media = true;
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("function_call_output");
+ try s.objectField("call_id");
+ try s.write(tr.tool_use_id);
+ try s.objectField("output");
+ var tbuf: std.ArrayList(u8) = .empty;
+ defer tbuf.deinit(allocator);
+ try tr.appendTextInto(allocator, &tbuf);
+ if (tr.hasMedia()) {
+ if (tbuf.items.len > 0) try tbuf.append(allocator, '\n');
+ try tbuf.appendSlice(allocator, "[attachment(s) provided in the following user message]");
+ }
+ try s.write(tbuf.items);
+ try s.endObject();
+ }
+ // Synthetic user message holding the media: images as
+ // `input_image` data URLs, PDFs as `input_file` (the
+ // Responses API rejects non-image data URLs in
+ // `input_image`).
+ if (any_media) {
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write("user");
+ try s.objectField("content");
+ try s.beginArray();
+ for (msg.content.items) |block| {
+ if (block != .ToolResult) continue;
+ for (block.ToolResult.parts.items) |part| {
+ if (part != .media) continue;
+ const m = part.media;
+ var url_buf: std.ArrayList(u8) = .empty;
+ defer url_buf.deinit(allocator);
+ try url_buf.appendSlice(allocator, "data:");
+ try url_buf.appendSlice(allocator, m.media_type);
+ try url_buf.appendSlice(allocator, ";base64,");
+ try url_buf.appendSlice(allocator, m.data.items);
+ const is_pdf = std.mem.eql(u8, m.media_type, "application/pdf");
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write(if (is_pdf) "input_file" else "input_image");
+ if (is_pdf) {
+ try s.objectField("filename");
+ try s.write("attachment.pdf");
+ try s.objectField("file_data");
+ } else {
+ try s.objectField("image_url");
+ }
+ try s.write(url_buf.items);
+ try s.endObject();
+ }
+ }
+ try s.endArray();
+ try s.endObject();
+ }
+ }
+ // Plain user text (skip if the message was purely tool results).
+ 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, "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;
+ if (!conversation.thinkingSignatureMatches(
+ tb,
+ msg.identity,
+ 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, openAIPhaseFromMetadata(msg.metadata));
+ }
+ for (msg.content.items) |block| {
+ if (block != .ToolUse) continue;
+ const tu = block.ToolUse;
+ try s.beginObject();
+ try s.objectField("type");
+ try s.write("function_call");
+ try s.objectField("call_id");
+ try s.write(tu.id);
+ try s.objectField("name");
+ var name_buf: [tool_registry_mod.max_wire_name_len]u8 = undefined;
+ try s.write(tool_registry_mod.encodeName(&name_buf, tu.name));
+ try s.objectField("arguments");
+ try s.write(tu.input.items);
+ try s.endObject();
+ }
+ },
+ }
+}
+
+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();
+ try s.objectField("type");
+ try s.write(content_type);
+ try s.objectField("text");
+ try s.write(text);
+ try s.endObject();
+ try s.endArray();
+ 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),
+ allocator: Allocator,
+) !void {
+ for (blocks) |block| {
+ switch (block) {
+ .Text => |tb| try out.appendSlice(allocator, tb.items),
+ .CompactionSummary => |cs| try out.appendSlice(allocator, cs.text.items),
+ else => {},
+ }
+ }
+}
+
+// ===========================================================================
+// Streaming event parsing
+// ===========================================================================
+
+/// The kinds of streaming event this provider acts on. Everything else is
+/// ignored (`.other`).
+pub const EventKind = enum {
+ output_item_added,
+ output_text_delta,
+ reasoning_summary_delta,
+ function_call_arguments_delta,
+ function_call_arguments_done,
+ output_item_done,
+ completed,
+ failed,
+ err,
+ other,
+};
+
+/// A parsed Responses streaming event. Slices borrow from `parsed`.
+pub const StreamEvent = struct {
+ parsed: std.json.Parsed(std.json.Value),
+ kind: EventKind,
+ /// `output_text`/`reasoning_summary` delta, or function-call argument
+ /// fragment.
+ delta: ?[]const u8 = null,
+ /// 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,
+ /// 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,
+ /// Full arguments string on `output_item.done` for a function_call.
+ arguments: ?[]const u8 = null,
+ /// Error/failure message (`error`, `response.failed`).
+ 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,
+ output_tokens: u64 = 0,
+ cached_tokens: u64 = 0,
+ reasoning_tokens: u64 = 0,
+ };
+
+ pub const OutputItem = struct {
+ output_index: usize,
+ call_id: ?[]const u8 = null,
+ name: ?[]const u8 = null,
+ arguments: ?[]const u8 = null,
+ };
+
+ pub fn deinit(self: *StreamEvent) void {
+ self.parsed.deinit();
+ }
+};
+
+/// Parse one SSE event payload (the JSON after `data: `). The caller must keep
+/// the returned value alive while reading its slices, then `deinit` it.
+pub fn parseStreamEvent(allocator: Allocator, payload: []const u8) !StreamEvent {
+ var parsed = try std.json.parseFromSlice(std.json.Value, allocator, payload, .{});
+ errdefer parsed.deinit();
+
+ var ev: StreamEvent = .{ .parsed = parsed, .kind = .other };
+ const root = parsed.value;
+ if (root != .object) return ev;
+ const obj = root.object;
+
+ const type_str = strField(obj, "type") orelse {
+ ev.parsed = parsed;
+ return ev;
+ };
+
+ if (std.mem.eql(u8, type_str, "response.output_text.delta")) {
+ ev.kind = .output_text_delta;
+ ev.delta = strField(obj, "delta");
+ 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.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.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.output_index = usizeField(obj, "output_index");
+ } else if (std.mem.eql(u8, type_str, "response.output_item.added")) {
+ ev.kind = .output_item_added;
+ 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;
+ 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);
+ } else if (std.mem.eql(u8, type_str, "error")) {
+ ev.kind = .err;
+ ev.error_message = strField(obj, "message") orelse "stream error";
+ }
+
+ ev.parsed = parsed;
+ return ev;
+}
+
+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_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 {
+ const resp = obj.get("response") orelse return;
+ if (resp != .object) return;
+ const u = resp.object.get("usage") orelse return;
+ if (u != .object) return;
+ var usage: StreamEvent.Usage = .{};
+ usage.input_tokens = u64Field(u.object, "input_tokens");
+ usage.output_tokens = u64Field(u.object, "output_tokens");
+ usage.reasoning_tokens = blk: {
+ const otd = u.object.get("output_tokens_details") orelse break :blk 0;
+ if (otd != .object) break :blk 0;
+ break :blk u64Field(otd.object, "reasoning_tokens");
+ };
+ usage.cached_tokens = blk: {
+ const itd = u.object.get("input_tokens_details") orelse break :blk 0;
+ if (itd != .object) break :blk 0;
+ break :blk u64Field(itd.object, "cached_tokens");
+ };
+ 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,
+ .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;
+ if (resp.object.get("error")) |e| {
+ if (e == .object) return strField(e.object, "message");
+ }
+ if (resp.object.get("incomplete_details")) |d| {
+ if (d == .object) return strField(d.object, "reason");
+ }
+ return null;
+}
+
+fn strField(obj: std.json.ObjectMap, name: []const u8) ?[]const u8 {
+ const v = obj.get(name) orelse return null;
+ return if (v == .string) v.string else null;
+}
+
+fn u64Field(obj: std.json.ObjectMap, name: []const u8) u64 {
+ const v = obj.get(name) orelse return 0;
+ if (v != .integer or v.integer < 0) return 0;
+ 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
+// ===========================================================================
+
+const testing = std.testing;
+
+fn testConfig(model: []const u8) config_mod.OpenAIResponsesConfig {
+ return .{ .api_key = "k", .base_url = "u", .model = model };
+}
+
+fn emptyTools() tool_registry_mod.ToolRegistry {
+ return tool_registry_mod.ToolRegistry.init(testing.allocator);
+}
+
+fn addUserText(conv: *conversation.Conversation, text: []const u8) !void {
+ const tb = try conversation.textualBlockFromSlice(conv.allocator, text);
+ var block: conversation.ContentBlock = .{ .Text = tb };
+ errdefer block.deinit(conv.allocator);
+ try conv.addUserMessage(&.{block});
+}
+
+test "responses serializeRequest - instructions, input, store/include" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addSystemMessage("You are Codex.");
+ try addUserText(&conv, "Hello!");
+
+ var cfg = testConfig("gpt-5.1-codex");
+ cfg.reasoning = .high;
+ var tools = emptyTools();
+ defer tools.deinit();
+ 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();
+ const root = parsed.value.object;
+
+ try testing.expectEqualStrings("gpt-5.1-codex", root.get("model").?.string);
+ try testing.expect(root.get("stream").?.bool);
+ try testing.expect(!root.get("store").?.bool);
+ try testing.expectEqualStrings("You are Codex.", root.get("instructions").?.string);
+ try testing.expectEqualStrings("reasoning.encrypted_content", root.get("include").?.array.items[0].string);
+ try testing.expectEqualStrings("high", root.get("reasoning").?.object.get("effort").?.string);
+
+ const input = root.get("input").?.array.items;
+ try testing.expectEqual(@as(usize, 1), input.len);
+ try testing.expectEqualStrings("user", input[0].object.get("role").?.string);
+ const part = input[0].object.get("content").?.array.items[0].object;
+ try testing.expectEqualStrings("input_text", part.get("type").?.string);
+ try testing.expectEqualStrings("Hello!", part.get("text").?.string);
+}
+
+test "responses serializeRequest - tools are flat function items" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "go");
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ try tools.register(.{
+ .decl = .{ .name = "echo", .description = "Echo.", .schema_json = "{\"type\":\"object\"}" },
+ .ctx = undefined,
+ .vtable = &NoopToolVT.v,
+ });
+
+ const cfg = testConfig("gpt-5.1-codex");
+ 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();
+
+ const tool0 = parsed.value.object.get("tools").?.array.items[0].object;
+ // Flat shape: name/description/parameters directly on the tool object.
+ try testing.expectEqualStrings("function", tool0.get("type").?.string);
+ try testing.expectEqualStrings("echo", tool0.get("name").?.string);
+ try testing.expectEqualStrings("Echo.", tool0.get("description").?.string);
+ 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);
+ defer conv.deinit();
+
+ const id = try allocator.dupe(u8, "call_1");
+ const name = try allocator.dupe(u8, "echo");
+ var args: conversation.TextualBlock = .empty;
+ try args.appendSlice(allocator, "{\"m\":\"hi\"}");
+ try conv.addAssistantMessage(&.{
+ .{ .Text = try conversation.textualBlockFromSlice(allocator, "calling") },
+ .{ .ToolUse = .{ .id = id, .name = name, .input = args } },
+ }, null);
+
+ const rid = try allocator.dupe(u8, "call_1");
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "42") });
+ var content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = rid, .parts = parts } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = content });
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-5.1-codex");
+ 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();
+
+ const input = parsed.value.object.get("input").?.array.items;
+ // assistant message (text) + function_call + function_call_output = 3.
+ try testing.expectEqual(@as(usize, 3), input.len);
+ try testing.expectEqualStrings("assistant", input[0].object.get("role").?.string);
+ try testing.expectEqualStrings("function_call", input[1].object.get("type").?.string);
+ try testing.expectEqualStrings("call_1", input[1].object.get("call_id").?.string);
+ try testing.expectEqualStrings("echo", input[1].object.get("name").?.string);
+ try testing.expectEqualStrings("function_call_output", input[2].object.get("type").?.string);
+ try testing.expectEqualStrings("call_1", input[2].object.get("call_id").?.string);
+ try testing.expectEqualStrings("42", input[2].object.get("output").?.string);
+}
+
+test "responses serializeRequest - tool result media splits into output note + synthetic user" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+
+ const rid = try allocator.dupe(u8, "call_img");
+ var parts: std.ArrayList(conversation.ResultPartStored) = .empty;
+ try parts.append(allocator, .{ .text = try conversation.textualBlockFromSlice(allocator, "the file:") });
+ try parts.append(allocator, .{ .media = .{
+ .media_type = try allocator.dupe(u8, "image/png"),
+ .data = try conversation.textualBlockFromSlice(allocator, "iVBOR=="),
+ } });
+ try parts.append(allocator, .{ .media = .{
+ .media_type = try allocator.dupe(u8, "application/pdf"),
+ .data = try conversation.textualBlockFromSlice(allocator, "JVBERg=="),
+ } });
+ var content: std.ArrayList(conversation.ContentBlock) = .empty;
+ try content.append(allocator, .{ .ToolResult = .{ .tool_use_id = rid, .parts = parts } });
+ try conv.messages.append(allocator, .{ .role = .user, .content = content });
+
+ var tools = emptyTools();
+ defer tools.deinit();
+ const cfg = testConfig("gpt-5.1-codex");
+ 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();
+
+ const input = parsed.value.object.get("input").?.array.items;
+ // function_call_output + synthetic user message = 2.
+ try testing.expectEqual(@as(usize, 2), input.len);
+
+ // The output carries the text plus the attachment note.
+ try testing.expectEqualStrings("function_call_output", input[0].object.get("type").?.string);
+ const out_text = input[0].object.get("output").?.string;
+ try testing.expect(std.mem.indexOf(u8, out_text, "the file:") != null);
+ try testing.expect(std.mem.indexOf(u8, out_text, "[attachment(s) provided in the following user message]") != null);
+
+ // The synthetic user message carries the image and the PDF.
+ try testing.expectEqualStrings("user", input[1].object.get("role").?.string);
+ const uc = input[1].object.get("content").?.array.items;
+ try testing.expectEqual(@as(usize, 2), uc.len);
+ try testing.expectEqualStrings("input_image", uc[0].object.get("type").?.string);
+ try testing.expectEqualStrings("data:image/png;base64,iVBOR==", uc[0].object.get("image_url").?.string);
+ try testing.expectEqualStrings("input_file", uc[1].object.get("type").?.string);
+ try testing.expectEqualStrings("attachment.pdf", uc[1].object.get("filename").?.string);
+ try testing.expectEqualStrings("data:application/pdf;base64,JVBERg==", uc[1].object.get("file_data").?.string);
+}
+
+test "responses parseStreamEvent - output_text delta" {
+ const allocator = testing.allocator;
+ var ev = try parseStreamEvent(allocator,
+ \\{"type":"response.output_text.delta","item_id":"msg_1","delta":"Hi"}
+ );
+ defer ev.deinit();
+ try testing.expectEqual(EventKind.output_text_delta, ev.kind);
+ try testing.expectEqualStrings("Hi", ev.delta.?);
+}
+
+test "responses parseStreamEvent - function_call item added/done + args delta" {
+ const allocator = testing.allocator;
+ var added = try parseStreamEvent(allocator,
+ \\{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"echo"}}
+ );
+ defer added.deinit();
+ try testing.expectEqual(EventKind.output_item_added, added.kind);
+ try testing.expectEqualStrings("function_call", added.item_type.?);
+ try testing.expectEqualStrings("call_9", added.call_id.?);
+ try testing.expectEqualStrings("echo", added.name.?);
+
+ var d = try parseStreamEvent(allocator,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"x\":1}"}
+ );
+ defer d.deinit();
+ try testing.expectEqual(EventKind.function_call_arguments_delta, d.kind);
+ 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" {
+ const allocator = testing.allocator;
+ var ev = try parseStreamEvent(allocator,
+ \\{"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20,"input_tokens_details":{"cached_tokens":80},"output_tokens_details":{"reasoning_tokens":8}}}}
+ );
+ defer ev.deinit();
+ try testing.expectEqual(EventKind.completed, ev.kind);
+ try testing.expectEqual(@as(u64, 100), ev.usage.?.input_tokens);
+ try testing.expectEqual(@as(u64, 20), ev.usage.?.output_tokens);
+ try testing.expectEqual(@as(u64, 80), ev.usage.?.cached_tokens);
+ 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("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,
+ \\{"type":"error","message":"boom"}
+ );
+ defer e.deinit();
+ try testing.expectEqual(EventKind.err, e.kind);
+ try testing.expectEqualStrings("boom", e.error_message.?);
+
+ var f = try parseStreamEvent(allocator,
+ \\{"type":"response.failed","response":{"error":{"message":"bad"}}}
+ );
+ defer f.deinit();
+ try testing.expectEqual(EventKind.failed, f.kind);
+ try testing.expectEqualStrings("bad", f.error_message.?);
+}
+
+const tool_mod = @import("tool.zig");
+const NoopToolVT = struct {
+ fn invoke(_: *anyopaque, _: []const u8, _: Allocator) anyerror!tool_mod.ResultParts {
+ return error.NotImplementedInTest;
+ }
+ fn deinit_(_: *anyopaque, _: Allocator) void {}
+ const v: tool_mod.Tool.VTable = .{ .invoke = invoke, .deinit = deinit_ };
+};