diff options
Diffstat (limited to 'libpanto/src/openai_responses_json.zig')
| -rw-r--r-- | libpanto/src/openai_responses_json.zig | 941 |
1 files changed, 0 insertions, 941 deletions
diff --git a/libpanto/src/openai_responses_json.zig b/libpanto/src/openai_responses_json.zig deleted file mode 100644 index 358fc45..0000000 --- a/libpanto/src/openai_responses_json.zig +++ /dev/null @@ -1,941 +0,0 @@ -//! 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_ }; -}; |
