summaryrefslogtreecommitdiff
path: root/libpanto/src/openai_responses_json.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-07-07 11:29:23 -0600
committert <t@tjp.lol>2026-07-07 12:15:28 -0600
commit621d7fee0ace729f8d44126032d2c6e13f72ee7f (patch)
treea2938b0a9e5c0930e8644721abbe94875df9ff08 /libpanto/src/openai_responses_json.zig
parent0fa6d4209ff9b4a95e7d1955887aa4c73ee3423c (diff)
Move libpanto projects to libpantograph dependencyHEADmain
Remove the in-repo libpanto sources and binding projects from pantograph. Consume libpantograph through the Zig package URL at code.tjp.lol/libpantograph.git, including the Lua module artifact used by CLI extensions.
Diffstat (limited to 'libpanto/src/openai_responses_json.zig')
-rw-r--r--libpanto/src/openai_responses_json.zig941
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_ };
-};