summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-13 12:22:18 -0600
committert <t@tjp.lol>2026-06-15 15:08:32 -0600
commiteb19fd812e15de5a6930daf2fcd0b2b0757687c7 (patch)
treeace85c641e48836fe45b289141213fe8984ccb52
parent9f983c5d0ecc1c12f15eadf1add123e24995b660 (diff)
auth: openai_responses provider for Codex subscription (Tier 2)
Add the OpenAI Responses API wire dialect (openai_responses APIStyle + OpenAIResponsesConfig), a request serializer (instructions/input items, flat function tools, reasoning, store:false, include encrypted reasoning), and a streaming state machine over the typed response.* SSE events mapping to the shared block model. Wire dispatch, buildProviderConfig, the C-ABI free switch, and the reasoning selectors. Ship a commented Codex example in the default config. Implemented from the Responses API docs + the open-source Codex client and covered by fixture tests; live verification against a ChatGPT plan is still required (documented).
-rw-r--r--libpanto-c/src/lib.zig8
-rw-r--r--libpanto/src/config.zig24
-rw-r--r--libpanto/src/openai_responses_json.zig582
-rw-r--r--libpanto/src/provider.zig12
-rw-r--r--libpanto/src/provider_openai_responses.zig656
-rw-r--r--libpanto/src/public.zig5
-rw-r--r--src/auth_manager.zig5
-rw-r--r--src/config_file.zig8
-rw-r--r--src/luarocks_runtime.zig24
-rw-r--r--src/tui_selectors.zig16
10 files changed, 1335 insertions, 5 deletions
diff --git a/libpanto-c/src/lib.zig b/libpanto-c/src/lib.zig
index 78202fb..888c6d6 100644
--- a/libpanto-c/src/lib.zig
+++ b/libpanto-c/src/lib.zig
@@ -368,6 +368,14 @@ fn freeProvider(pv: *panto.ProviderConfig) void {
allocator.free(c.model);
if (c.api_version.ptr != "2023-06-01".ptr) allocator.free(c.api_version);
},
+ // The Responses style is not constructible across the C ABI (no
+ // PantoAPIStyle variant), so `convertProvider` never builds it; this
+ // case exists only to keep the union switch exhaustive.
+ .openai_responses => |c| {
+ allocator.free(c.api_key);
+ allocator.free(c.base_url);
+ allocator.free(c.model);
+ },
}
}
diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig
index 09aaec8..c48cefc 100644
--- a/libpanto/src/config.zig
+++ b/libpanto/src/config.zig
@@ -24,6 +24,9 @@ const Io = std.Io;
pub const APIStyle = enum {
openai_chat,
anthropic_messages,
+ /// OpenAI Responses API (`/responses`). Used by the ChatGPT-subscription
+ /// Codex backend, whose request/stream shape differs from Chat Completions.
+ openai_responses,
};
/// A single HTTP request header (name/value). Used for provider
@@ -133,10 +136,25 @@ pub const AnthropicMessagesConfig = struct {
extra_headers: []const Header = &.{},
};
+/// OpenAI Responses API config. Same transport knobs as `openai_chat`, but a
+/// distinct request body (`input` items, `reasoning`, `store`, `include`) and
+/// streaming event shape. Auth-derived headers (`chatgpt-account-id`) and the
+/// static Codex identity headers (`OpenAI-Beta`, `originator`) ride on
+/// `extra_headers`.
+pub const OpenAIResponsesConfig = struct {
+ api_key: []const u8,
+ base_url: []const u8,
+ model: []const u8,
+ reasoning: ReasoningEffort = .default,
+ max_tokens: u32 = 64_000,
+ extra_headers: []const Header = &.{},
+};
+
/// Per-provider transport/auth/model configuration. Tagged by `APIStyle`.
pub const ProviderConfig = union(APIStyle) {
openai_chat: OpenAIChatConfig,
anthropic_messages: AnthropicMessagesConfig,
+ openai_responses: OpenAIResponsesConfig,
pub fn style(self: ProviderConfig) APIStyle {
return @as(APIStyle, self);
@@ -163,6 +181,12 @@ pub const ProviderConfig = union(APIStyle) {
.thinking_budget_tokens = c.thinking_budget_tokens,
.thinking_interleaved = c.thinking_interleaved,
},
+ .openai_responses => |c| .{
+ .api_style = .openai_responses,
+ .base_url = c.base_url,
+ .model = c.model,
+ .reasoning = c.reasoning,
+ },
};
}
};
diff --git a/libpanto/src/openai_responses_json.zig b/libpanto/src/openai_responses_json.zig
new file mode 100644
index 0000000..8ff7712
--- /dev/null
+++ b/libpanto/src/openai_responses_json.zig
@@ -0,0 +1,582 @@
+//! 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");
+
+// ===========================================================================
+// Request serialization
+// ===========================================================================
+
+/// 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,
+) ![]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);
+
+ try s.objectField("max_output_tokens");
+ try s.write(cfg.max_tokens);
+
+ // 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);
+ }
+ 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) !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) {
+ for (msg.content.items) |block| {
+ if (block != .ToolResult) continue;
+ const tr = block.ToolResult;
+ 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);
+ try s.write(tbuf.items);
+ 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);
+ }
+ },
+ .assistant => {
+ // 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);
+ }
+ 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) !void {
+ try s.beginObject();
+ try s.objectField("role");
+ try s.write(role);
+ 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 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 => {},
+ }
+ }
+}
+
+fn writeRawJson(s: *std.json.Stringify, raw: []const u8) !void {
+ var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
+ defer arena.deinit();
+ const parsed = std.json.parseFromSlice(std.json.Value, arena.allocator(), raw, .{}) catch {
+ try s.beginObject();
+ try s.endObject();
+ return;
+ };
+ try s.write(parsed.value);
+}
+
+// ===========================================================================
+// 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,
+ 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,
+ /// Item id (`response.output_item.added/done`, function-call argument
+ /// deltas reference `item_id`).
+ item_id: ?[]const u8 = null,
+ /// Item type on add/done: "message" | "function_call" | "reasoning".
+ item_type: ?[]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,
+
+ pub const Usage = struct {
+ input_tokens: u64 = 0,
+ output_tokens: u64 = 0,
+ cached_tokens: u64 = 0,
+ reasoning_tokens: u64 = 0,
+ };
+
+ 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.item_id = strField(obj, "item_id");
+ } else if (std.mem.eql(u8, type_str, "response.reasoning_summary_text.delta")) {
+ ev.kind = .reasoning_summary_delta;
+ ev.delta = strField(obj, "delta");
+ ev.item_id = strField(obj, "item_id");
+ } else if (std.mem.eql(u8, type_str, "response.function_call_arguments.delta")) {
+ ev.kind = .function_call_arguments_delta;
+ ev.delta = strField(obj, "delta");
+ ev.item_id = strField(obj, "item_id");
+ } else if (std.mem.eql(u8, type_str, "response.output_item.added")) {
+ ev.kind = .output_item_added;
+ readItem(obj, &ev);
+ } else if (std.mem.eql(u8, type_str, "response.output_item.done")) {
+ ev.kind = .output_item_done;
+ readItem(obj, &ev);
+ } else if (std.mem.eql(u8, type_str, "response.completed")) {
+ ev.kind = .completed;
+ readUsage(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(obj: std.json.ObjectMap, ev: *StreamEvent) void {
+ const item = obj.get("item") orelse return;
+ if (item != .object) return;
+ const io = item.object;
+ ev.item_id = strField(io, "id");
+ ev.item_type = strField(io, "type");
+ ev.call_id = strField(io, "call_id");
+ ev.name = strField(io, "name");
+ ev.arguments = strField(io, "arguments");
+}
+
+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 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);
+}
+
+// ===========================================================================
+// 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);
+ 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);
+ 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 - 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);
+ 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 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("fc_1", d.item_id.?);
+ try testing.expectEqualStrings("{\"x\":1}", d.delta.?);
+}
+
+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 - 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_ };
+};
diff --git a/libpanto/src/provider.zig b/libpanto/src/provider.zig
index 43c882a..9c715b7 100644
--- a/libpanto/src/provider.zig
+++ b/libpanto/src/provider.zig
@@ -290,6 +290,7 @@ pub fn openStream(
// provider.zig <- provider_openai_chat.zig <- provider.zig.
const provider_openai_chat = @import("provider_openai_chat.zig");
const provider_anthropic_messages = @import("provider_anthropic_messages.zig");
+ const provider_openai_responses = @import("provider_openai_responses.zig");
const client = config_mod.httpClient();
switch (cfg.provider) {
.openai_chat => |*c| {
@@ -314,6 +315,17 @@ pub fn openStream(
const rr = try req.open(conv, registry);
return rr.providerStream();
},
+ .openai_responses => |*c| {
+ var req: provider_openai_responses.OpenAIResponsesRequest = .{
+ .allocator = allocator,
+ .io = io,
+ .config = c,
+ .http_client = client,
+ .diag = diag,
+ };
+ const rr = try req.open(conv, registry);
+ return rr.providerStream();
+ },
}
}
diff --git a/libpanto/src/provider_openai_responses.zig b/libpanto/src/provider_openai_responses.zig
new file mode 100644
index 0000000..92a309b
--- /dev/null
+++ b/libpanto/src/provider_openai_responses.zig
@@ -0,0 +1,656 @@
+//! OpenAI Responses API streaming provider (ChatGPT-subscription Codex).
+//!
+//! Mirrors `provider_openai_chat.zig` in shape — a transient request object
+//! that opens the HTTP stream and a heap-pinned `ResumableResponse` that pumps
+//! SSE bytes into `Event`s — but speaks the Responses streaming protocol
+//! (typed `response.*` events) instead of Chat Completions `choices[].delta`.
+//!
+//! Event → block mapping:
+//! - `response.output_text.delta` → Text block deltas
+//! - `response.reasoning_summary_text.delta` → Thinking block deltas
+//! - `response.output_item.added` (function_call) → opens a ToolUse block
+//! - `response.function_call_arguments.delta` → ToolUse input deltas
+//! - `response.output_item.done` (function_call) → closes the ToolUse
+//! - `response.completed` → usage + finalize
+//! - `error` / `response.failed` → malformed-stream error
+//!
+//! NOTE: the Responses-backed Codex path could not be verified against live
+//! ChatGPT-subscription credentials; the request/stream shapes follow the
+//! OpenAI Responses API docs and the open-source Codex client. Fixture tests
+//! exercise the state machine; live verification is still required.
+
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Io = std.Io;
+const http = std.http;
+const Uri = std.Uri;
+
+const conversation = @import("conversation.zig");
+const provider_mod = @import("provider.zig");
+const stream_mod = @import("stream.zig");
+const sse_mod = @import("sse.zig");
+const json_mod = @import("openai_responses_json.zig");
+const config_mod = @import("config.zig");
+const tool_registry_mod = @import("tool_registry.zig");
+
+const Event = stream_mod.Event;
+const EventQueue = stream_mod.EventQueue;
+
+fn decodeNameInPlace(name_buf: *conversation.TextualBlock) void {
+ const decoded = tool_registry_mod.decodeName(name_buf.items, name_buf.items);
+ name_buf.items.len = decoded.len;
+}
+
+pub const OpenAIResponsesRequest = struct {
+ allocator: Allocator,
+ io: Io,
+ config: *const config_mod.OpenAIResponsesConfig,
+ http_client: *http.Client,
+ diag: ?*provider_mod.ProviderDiagnostic = null,
+
+ pub fn open(
+ self: *OpenAIResponsesRequest,
+ conv: *conversation.Conversation,
+ tools: *const provider_mod.ToolRegistry,
+ ) !*ResumableResponse {
+ const rr = try self.allocator.create(ResumableResponse);
+ errdefer self.allocator.destroy(rr);
+ rr.* = .{
+ .allocator = self.allocator,
+ .conv = conv,
+ .parser = sse_mod.SSEParser.init(self.allocator),
+ .state = .init(self.allocator),
+ };
+ errdefer {
+ rr.parser.deinit();
+ rr.state.deinit();
+ }
+
+ const url = try std.fmt.allocPrint(self.allocator, "{s}/responses", .{self.config.base_url});
+ defer self.allocator.free(url);
+ const uri = try Uri.parse(url);
+
+ const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools);
+ defer self.allocator.free(body);
+
+ const auth_value = try std.fmt.allocPrint(self.allocator, "Bearer {s}", .{self.config.api_key});
+ defer self.allocator.free(auth_value);
+
+ const base_headers = [_]http.Header{
+ .{ .name = "content-type", .value = "application/json" },
+ .{ .name = "accept", .value = "text/event-stream" },
+ .{ .name = "authorization", .value = auth_value },
+ };
+ const extra_headers = try provider_mod.mergeHeaders(
+ self.allocator,
+ &base_headers,
+ self.config.extra_headers,
+ );
+ defer self.allocator.free(extra_headers);
+
+ rr.req = try self.http_client.request(.POST, uri, .{
+ .extra_headers = extra_headers,
+ .headers = .{ .accept_encoding = .{ .override = "identity" } },
+ .keep_alive = false,
+ .redirect_behavior = .not_allowed,
+ });
+ rr.req_open = true;
+ errdefer {
+ rr.req.deinit();
+ rr.req_open = false;
+ }
+
+ rr.req.transfer_encoding = .{ .content_length = body.len };
+ var send_buf: [4096]u8 = undefined;
+ var bw = try rr.req.sendBodyUnflushed(&send_buf);
+ try bw.writer.writeAll(body);
+ try bw.end();
+ try rr.req.connection.?.flush();
+
+ var redirect_buf: [1024]u8 = undefined;
+ rr.response = try rr.req.receiveHead(&redirect_buf);
+
+ if (@intFromEnum(rr.response.head.status) >= 400) {
+ const retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
+ const body_reader = rr.response.reader(&rr.transfer_buf);
+ var err_buf: std.ArrayList(u8) = .empty;
+ defer err_buf.deinit(self.allocator);
+ var tmp: [1024]u8 = undefined;
+ while (true) {
+ const n = body_reader.readSliceShort(&tmp) catch break;
+ if (n == 0) break;
+ try err_buf.appendSlice(self.allocator, tmp[0..n]);
+ if (err_buf.items.len > 16 * 1024) break;
+ }
+ const status: u16 = @intFromEnum(rr.response.head.status);
+ std.log.err("openai_responses HTTP {d}: {s}", .{ status, err_buf.items });
+ const classified = provider_mod.classifyHttpStatus(status, err_buf.items);
+ if (self.diag) |d| {
+ d.status_code = status;
+ d.retry_after_ms = retry_after_ms;
+ }
+ return classified;
+ }
+
+ rr.body_reader = rr.response.reader(&rr.transfer_buf);
+ return rr;
+ }
+};
+
+pub const ResumableResponse = struct {
+ allocator: Allocator,
+ conv: *conversation.Conversation,
+ parser: sse_mod.SSEParser,
+ state: StreamState,
+
+ req: http.Client.Request = undefined,
+ response: http.Client.Response = undefined,
+ transfer_buf: [4096]u8 = undefined,
+ body_reader: *std.Io.Reader = undefined,
+ chunk: [4096]u8 = undefined,
+
+ req_open: bool = false,
+ done: bool = false,
+
+ pub const ProduceStatus = provider_mod.ProviderStream.ProduceStatus;
+
+ pub fn providerStream(self: *ResumableResponse) provider_mod.ProviderStream {
+ return .{ .ptr = self, .vtable = &vtable };
+ }
+
+ const vtable: provider_mod.ProviderStream.VTable = .{
+ .produce = produceVT,
+ .deinit = deinitVT,
+ .last_error = lastErrorVT,
+ };
+
+ fn lastErrorVT(ptr: *anyopaque) ?[]const u8 {
+ const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
+ return self.state.stream_error_message;
+ }
+ fn produceVT(ptr: *anyopaque, out: *EventQueue) anyerror!ProduceStatus {
+ const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
+ return self.produce(out);
+ }
+ fn deinitVT(ptr: *anyopaque) void {
+ const self: *ResumableResponse = @ptrCast(@alignCast(ptr));
+ self.deinit();
+ }
+
+ pub fn deinit(self: *ResumableResponse) void {
+ if (self.req_open) self.req.deinit();
+ self.parser.deinit();
+ self.state.deinit();
+ self.allocator.destroy(self);
+ }
+
+ pub fn produce(self: *ResumableResponse, out: *EventQueue) !ProduceStatus {
+ if (self.done) return .response_complete;
+
+ var vecs: [1][]u8 = .{&self.chunk};
+ const n = self.body_reader.readVec(&vecs) catch |err| switch (err) {
+ error.EndOfStream => {
+ try self.finishStream(out);
+ return .response_complete;
+ },
+ else => return error.ProviderStreamMalformed,
+ };
+ if (n == 0) return .more;
+
+ const events = try self.parser.feed(self.chunk[0..n]);
+ defer self.parser.freeEvents(events);
+
+ for (events) |ev_payload| {
+ std.log.debug("openai_responses <= {s}", .{ev_payload});
+ // The Responses stream has no `[DONE]` sentinel; `response.completed`
+ // is the terminal event.
+ if (std.mem.eql(u8, ev_payload, "[DONE]")) {
+ try self.finishStream(out);
+ return .response_complete;
+ }
+ const terminal = try handleEvent(self.allocator, ev_payload, &self.state, out);
+ if (terminal) {
+ try self.finishStream(out);
+ return .response_complete;
+ }
+ }
+ return .more;
+ }
+
+ fn finishStream(self: *ResumableResponse, out: *EventQueue) !void {
+ if (self.done) return;
+ self.done = true;
+ try self.state.finalize(out, self.conv);
+ }
+};
+
+const ActiveBlock = enum { none, text, thinking };
+
+const StreamState = struct {
+ allocator: Allocator,
+ started: bool = false,
+ finalized: bool = false,
+ active: ActiveBlock = .none,
+ block_index: usize = 0,
+ current_buf: conversation.TextualBlock = .empty,
+ blocks: std.ArrayList(conversation.ContentBlock) = .empty,
+ /// In-progress tool calls keyed by their streaming `item_id`.
+ tools: std.StringArrayHashMapUnmanaged(ToolUseInProgress) = .empty,
+ usage: ?provider_mod.Usage = null,
+ stream_error_message: ?[]u8 = null,
+
+ const ToolUseInProgress = struct {
+ block_index: usize,
+ id_buf: conversation.TextualBlock = .empty, // call_id
+ name_buf: conversation.TextualBlock = .empty,
+ arguments: conversation.TextualBlock = .empty,
+
+ fn deinit(self: *ToolUseInProgress, allocator: Allocator) void {
+ self.id_buf.deinit(allocator);
+ self.name_buf.deinit(allocator);
+ self.arguments.deinit(allocator);
+ }
+ };
+
+ fn init(allocator: Allocator) StreamState {
+ return .{ .allocator = allocator };
+ }
+
+ fn deinit(self: *StreamState) void {
+ self.current_buf.deinit(self.allocator);
+ for (self.blocks.items) |*b| b.deinit(self.allocator);
+ self.blocks.deinit(self.allocator);
+ var it = self.tools.iterator();
+ while (it.next()) |e| {
+ self.allocator.free(e.key_ptr.*);
+ e.value_ptr.deinit(self.allocator);
+ }
+ self.tools.deinit(self.allocator);
+ if (self.stream_error_message) |s| self.allocator.free(s);
+ }
+
+ fn setStreamErrorMessage(self: *StreamState, message: []const u8) void {
+ if (self.stream_error_message) |old| self.allocator.free(old);
+ self.stream_error_message = self.allocator.dupe(u8, message) catch null;
+ }
+
+ fn ensureStarted(self: *StreamState, out: *EventQueue) !void {
+ if (self.started) return;
+ self.started = true;
+ try out.push(.{ .message_start = .assistant });
+ }
+
+ fn closeActive(self: *StreamState, out: *EventQueue) !void {
+ if (self.active == .none) return;
+ const block: conversation.ContentBlock = switch (self.active) {
+ .text => .{ .Text = self.current_buf },
+ .thinking => .{ .Thinking = .{ .text = self.current_buf } },
+ .none => unreachable,
+ };
+ self.current_buf = .empty;
+ try self.blocks.append(self.allocator, block);
+ try out.push(.{ .block_complete = .{
+ .index = self.block_index,
+ .block = self.blocks.items[self.blocks.items.len - 1],
+ } });
+ self.active = .none;
+ }
+
+ fn openBlock(self: *StreamState, new_active: ActiveBlock, out: *EventQueue) !void {
+ std.debug.assert(new_active == .text or new_active == .thinking);
+ if (self.active == new_active) return;
+ if (self.active != .none) {
+ try self.closeActive(out);
+ self.block_index += 1;
+ }
+ self.active = new_active;
+ const block_type: provider_mod.ContentBlockType = switch (new_active) {
+ .text => .Text,
+ .thinking => .Thinking,
+ .none => unreachable,
+ };
+ try out.push(.{ .block_start = .{ .block_type = block_type, .index = self.block_index } });
+ }
+
+ fn appendDelta(self: *StreamState, out: *EventQueue, delta: []const u8) !void {
+ try self.current_buf.appendSlice(self.allocator, delta);
+ try out.push(.{ .content_delta = .{
+ .index = self.block_index,
+ .delta = try out.dupeBytes(delta),
+ } });
+ }
+
+ /// Open a new ToolUse block for a `function_call` output item.
+ fn openToolUse(
+ self: *StreamState,
+ out: *EventQueue,
+ item_id: []const u8,
+ call_id: ?[]const u8,
+ name: ?[]const u8,
+ ) !void {
+ // Close any active text/thinking block so the tool gets its own index.
+ if (self.active != .none) {
+ try self.closeActive(out);
+ self.block_index += 1;
+ }
+ var tu: ToolUseInProgress = .{ .block_index = self.block_index };
+ if (call_id) |c| try tu.id_buf.appendSlice(self.allocator, c);
+ if (name) |nm| try tu.name_buf.appendSlice(self.allocator, nm);
+ const key = try self.allocator.dupe(u8, item_id);
+ try self.tools.put(self.allocator, key, tu);
+
+ try out.push(.{ .block_start = .{ .block_type = .ToolUse, .index = self.block_index } });
+ if (call_id != null and name != null) {
+ try out.push(.{ .tool_details = .{
+ .index = self.block_index,
+ .id = try out.dupeBytes(call_id.?),
+ .name = try out.dupeBytes(name.?),
+ } });
+ }
+ self.block_index += 1;
+ }
+
+ fn appendToolArgs(self: *StreamState, out: *EventQueue, item_id: []const u8, delta: []const u8) !void {
+ const tu = self.tools.getPtr(item_id) orelse return;
+ try tu.arguments.appendSlice(self.allocator, delta);
+ try out.push(.{ .content_delta = .{
+ .index = tu.block_index,
+ .delta = try out.dupeBytes(delta),
+ } });
+ }
+
+ /// Close a ToolUse block on `output_item.done`. `final_args` (when the
+ /// done event carries the full arguments) overrides the accumulated ones.
+ fn closeToolUse(
+ self: *StreamState,
+ out: *EventQueue,
+ item_id: []const u8,
+ final_args: ?[]const u8,
+ ) !void {
+ const entry = self.tools.fetchSwapRemove(item_id) orelse return;
+ self.allocator.free(entry.key);
+ var tu = entry.value;
+
+ if (tu.id_buf.items.len == 0 or tu.name_buf.items.len == 0) {
+ tu.deinit(self.allocator);
+ return;
+ }
+ if (final_args) |fa| {
+ tu.arguments.clearRetainingCapacity();
+ try tu.arguments.appendSlice(self.allocator, fa);
+ }
+ decodeNameInPlace(&tu.name_buf);
+
+ const id_owned = try tu.id_buf.toOwnedSlice(self.allocator);
+ const name_owned = try tu.name_buf.toOwnedSlice(self.allocator);
+ const block: conversation.ContentBlock = .{ .ToolUse = .{
+ .id = id_owned,
+ .name = name_owned,
+ .input = tu.arguments,
+ } };
+ tu.arguments = .empty;
+ try self.blocks.append(self.allocator, block);
+ try out.push(.{ .block_complete = .{
+ .index = tu.block_index,
+ .block = self.blocks.items[self.blocks.items.len - 1],
+ } });
+ }
+
+ fn finalize(self: *StreamState, out: *EventQueue, conv: *conversation.Conversation) !void {
+ if (self.finalized) return;
+ self.finalized = true;
+ try self.closeActive(out);
+ // Close any tool calls that never received an explicit done event.
+ var it = self.tools.iterator();
+ var leftover: std.ArrayList([]const u8) = .empty;
+ defer leftover.deinit(self.allocator);
+ while (it.next()) |e| leftover.append(self.allocator, e.key_ptr.*) catch {};
+ for (leftover.items) |k| try self.closeToolUse(out, k, null);
+
+ const moved_blocks = try self.blocks.toOwnedSlice(self.allocator);
+ defer self.allocator.free(moved_blocks);
+ try conv.addAssistantMessage(moved_blocks, self.usage);
+ const msg = conv.messages.items[conv.messages.items.len - 1];
+ try out.push(.{ .message_complete = .{ .message = msg, .usage = self.usage } });
+ }
+};
+
+/// Handle one parsed event. Returns true when the stream is terminal
+/// (`response.completed`) so the caller can finalize.
+fn handleEvent(
+ allocator: Allocator,
+ payload: []const u8,
+ state: *StreamState,
+ out: *EventQueue,
+) !bool {
+ var ev = try json_mod.parseStreamEvent(allocator, payload);
+ defer ev.deinit();
+
+ switch (ev.kind) {
+ .output_text_delta => {
+ if (ev.delta) |d| {
+ try state.ensureStarted(out);
+ try state.openBlock(.text, out);
+ try state.appendDelta(out, d);
+ }
+ },
+ .reasoning_summary_delta => {
+ if (ev.delta) |d| {
+ try state.ensureStarted(out);
+ try state.openBlock(.thinking, out);
+ try state.appendDelta(out, d);
+ }
+ },
+ .output_item_added => {
+ if (ev.item_type) |it| {
+ if (std.mem.eql(u8, it, "function_call")) {
+ try state.ensureStarted(out);
+ if (ev.item_id) |id| try state.openToolUse(out, id, ev.call_id, ev.name);
+ }
+ }
+ },
+ .function_call_arguments_delta => {
+ if (ev.item_id) |id| {
+ if (ev.delta) |d| try state.appendToolArgs(out, id, d);
+ }
+ },
+ .output_item_done => {
+ if (ev.item_type) |it| {
+ if (std.mem.eql(u8, it, "function_call")) {
+ if (ev.item_id) |id| try state.closeToolUse(out, id, ev.arguments);
+ }
+ }
+ },
+ .completed => {
+ if (ev.usage) |u| {
+ const cached = u.cached_tokens;
+ const total_in = u.input_tokens;
+ const fresh = if (cached > total_in) 0 else total_in - cached;
+ state.usage = .{
+ .input = fresh,
+ .output = u.output_tokens,
+ .cache_read = cached,
+ .cache_write = 0,
+ .reasoning = u.reasoning_tokens,
+ };
+ }
+ return true;
+ },
+ .failed, .err => {
+ if (ev.error_message) |m| {
+ std.log.err("openai_responses stream error: {s}", .{m});
+ state.setStreamErrorMessage(m);
+ }
+ return error.ProviderStreamMalformed;
+ },
+ .other => {},
+ }
+ return false;
+}
+
+// ===========================================================================
+// Tests
+// ===========================================================================
+
+const testing = std.testing;
+
+const EventRecorder = struct {
+ allocator: Allocator,
+ events: std.ArrayList([]const u8) = .empty,
+
+ fn deinit(self: *EventRecorder) void {
+ for (self.events.items) |e| self.allocator.free(e);
+ self.events.deinit(self.allocator);
+ }
+ fn push(self: *EventRecorder, comptime fmt: []const u8, args: anytype) !void {
+ try self.events.append(self.allocator, try std.fmt.allocPrint(self.allocator, fmt, args));
+ }
+ fn record(self: *EventRecorder, ev: Event) !void {
+ switch (ev) {
+ .message_start => try self.push("msg_start", .{}),
+ .block_start => |b| try self.push("block_start[{d}]:{s}", .{ b.index, @tagName(b.block_type) }),
+ .tool_details => |t| try self.push("tool_details[{d}]:{s}:{s}", .{ t.index, t.id, t.name }),
+ .content_delta => |d| try self.push("delta[{d}]:{s}", .{ d.index, d.delta }),
+ .block_complete => |b| try self.push("block_complete[{d}]", .{b.index}),
+ .message_complete => |m| {
+ if (m.usage) |u| {
+ try self.push("msg_complete[in={d},out={d},cr={d},rsn={d}]", .{ u.input, u.output, u.cache_read, u.reasoning });
+ } else try self.push("msg_complete[null]", .{});
+ },
+ else => {},
+ }
+ }
+};
+
+fn runStreamedTurn(
+ allocator: Allocator,
+ conv: *conversation.Conversation,
+ rec: ?*EventRecorder,
+ events: []const []const u8,
+) !void {
+ var state: StreamState = .init(allocator);
+ defer state.deinit();
+ var queue = EventQueue.init(allocator);
+ defer queue.deinit();
+
+ var terminal = false;
+ for (events) |payload| {
+ terminal = try handleEvent(allocator, payload, &state, &queue);
+ if (terminal) break;
+ }
+ try state.finalize(&queue, conv);
+ while (queue.pop()) |ev| {
+ if (rec) |r| try r.record(ev);
+ }
+}
+
+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 stream: reasoning then text then completed" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "hi");
+
+ var rec = EventRecorder{ .allocator = allocator };
+ defer rec.deinit();
+
+ const events = [_][]const u8{
+ \\{"type":"response.reasoning_summary_text.delta","item_id":"rs_1","delta":"thinking…"}
+ ,
+ \\{"type":"response.output_text.delta","item_id":"msg_1","delta":"Hello"}
+ ,
+ \\{"type":"response.output_text.delta","item_id":"msg_1","delta":" there"}
+ ,
+ \\{"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":5,"output_tokens_details":{"reasoning_tokens":2}}}}
+ ,
+ };
+ try runStreamedTurn(allocator, &conv, &rec, &events);
+
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 2), asst.content.items.len);
+ try testing.expectEqualStrings("thinking…", asst.content.items[0].Thinking.text.items);
+ try testing.expectEqualStrings("Hello there", asst.content.items[1].Text.items);
+
+ // Usage stamped.
+ try testing.expect(asst.usage != null);
+ try testing.expectEqual(@as(u64, 5), asst.usage.?.output);
+ try testing.expectEqual(@as(u64, 2), asst.usage.?.reasoning);
+}
+
+test "responses stream: function call assembles a ToolUse" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "call it");
+
+ var rec = EventRecorder{ .allocator = allocator };
+ defer rec.deinit();
+
+ const events = [_][]const u8{
+ \\{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"std__read"}}
+ ,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\"path\":"}
+ ,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\"a\"}"}
+ ,
+ \\{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"call_9","name":"std__read","arguments":"{\"path\":\"a\"}"}}
+ ,
+ \\{"type":"response.completed","response":{"usage":{"input_tokens":3,"output_tokens":1}}}
+ ,
+ };
+ try runStreamedTurn(allocator, &conv, &rec, &events);
+
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 1), asst.content.items.len);
+ const tu = asst.content.items[0].ToolUse;
+ try testing.expectEqualStrings("call_9", tu.id);
+ // Wire name `std__read` decoded to internal dotted form.
+ try testing.expectEqualStrings("std.read", tu.name);
+ try testing.expectEqualStrings("{\"path\":\"a\"}", tu.input.items);
+
+ // Callback order: start → details → deltas → complete.
+ const expect = [_][]const u8{
+ "msg_start",
+ "block_start[0]:ToolUse",
+ "tool_details[0]:call_9:std__read",
+ "delta[0]:{\"path\":",
+ "delta[0]:\"a\"}",
+ "block_complete[0]",
+ "msg_complete[in=3,out=1,cr=0,rsn=0]",
+ };
+ try testing.expectEqual(expect.len, rec.events.items.len);
+ for (expect, rec.events.items) |w, g| try testing.expectEqualStrings(w, g);
+}
+
+test "responses stream: text then tool call keeps block order" {
+ const allocator = testing.allocator;
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try addUserText(&conv, "go");
+
+ const events = [_][]const u8{
+ \\{"type":"response.output_text.delta","item_id":"msg_1","delta":"working"}
+ ,
+ \\{"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"c1","name":"ping"}}
+ ,
+ \\{"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{}"}
+ ,
+ \\{"type":"response.output_item.done","item":{"type":"function_call","id":"fc_1","call_id":"c1","name":"ping","arguments":"{}"}}
+ ,
+ \\{"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}
+ ,
+ };
+ try runStreamedTurn(allocator, &conv, null, &events);
+
+ const asst = conv.messages.items[1];
+ try testing.expectEqual(@as(usize, 2), asst.content.items.len);
+ try testing.expectEqualStrings("working", asst.content.items[0].Text.items);
+ try testing.expectEqualStrings("ping", asst.content.items[1].ToolUse.name);
+ try testing.expectEqualStrings("{}", asst.content.items[1].ToolUse.input.items);
+}
diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig
index ec8534a..339c6f1 100644
--- a/libpanto/src/public.zig
+++ b/libpanto/src/public.zig
@@ -67,6 +67,7 @@ pub const Config = config_mod.Config;
pub const ProviderConfig = config_mod.ProviderConfig;
pub const OpenAIChatConfig = config_mod.OpenAIChatConfig;
pub const AnthropicMessagesConfig = config_mod.AnthropicMessagesConfig;
+pub const OpenAIResponsesConfig = config_mod.OpenAIResponsesConfig;
pub const APIStyle = config_mod.APIStyle;
pub const ReasoningEffort = config_mod.ReasoningEffort;
pub const Thinking = config_mod.Thinking;
@@ -236,6 +237,8 @@ const openai_chat_json = @import("openai_chat_json.zig");
const provider_openai_chat = @import("provider_openai_chat.zig");
const anthropic_messages_json = @import("anthropic_messages_json.zig");
const provider_anthropic_messages = @import("provider_anthropic_messages.zig");
+const openai_responses_json = @import("openai_responses_json.zig");
+const provider_openai_responses = @import("provider_openai_responses.zig");
const compaction_mod = @import("compaction.zig");
const sse_mod = @import("sse.zig");
const tool_registry_mod = @import("tool_registry.zig");
@@ -274,4 +277,6 @@ test {
std.testing.refAllDecls(provider_openai_chat);
std.testing.refAllDecls(anthropic_messages_json);
std.testing.refAllDecls(provider_anthropic_messages);
+ std.testing.refAllDecls(openai_responses_json);
+ std.testing.refAllDecls(provider_openai_responses);
}
diff --git a/src/auth_manager.zig b/src/auth_manager.zig
index 263ec54..87b20ae 100644
--- a/src/auth_manager.zig
+++ b/src/auth_manager.zig
@@ -189,6 +189,11 @@ fn patchCredential(
c.base_url = base_url;
c.extra_headers = merged;
},
+ .openai_responses => |*c| {
+ c.api_key = cred.api_key;
+ c.base_url = base_url;
+ c.extra_headers = merged;
+ },
}
}
diff --git a/src/config_file.zig b/src/config_file.zig
index 1277661..cac003a 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -196,6 +196,14 @@ pub fn buildProviderConfig(
.prompt_cache = prov.prompt_cache,
.extra_headers = prov.extra_headers,
} },
+ .openai_responses => return .{ .openai_responses = .{
+ .api_key = api_key,
+ .base_url = prov.base_url,
+ .model = wire_model,
+ .reasoning = reasoning,
+ .max_tokens = max_tokens,
+ .extra_headers = prov.extra_headers,
+ } },
}
}
diff --git a/src/luarocks_runtime.zig b/src/luarocks_runtime.zig
index ac34ee0..3cfd02f 100644
--- a/src/luarocks_runtime.zig
+++ b/src/luarocks_runtime.zig
@@ -347,6 +347,30 @@ const default_base_config =
\\# Editor-Plugin-Version = "copilot-chat/0.26.7"
\\# Copilot-Integration-Id = "vscode-chat"
\\
+ \\# OpenAI Codex via a ChatGPT subscription (device login). Codex speaks the
+ \\# OpenAI Responses API, not Chat Completions, so style = "openai_responses".
+ \\# NOTE: this path is implemented but not yet verified against a live
+ \\# ChatGPT plan — see docs/oauth-provider-auth.md.
+ \\#
+ \\# [providers.codex]
+ \\# style = "openai_responses"
+ \\# base_url = "https://chatgpt.com/backend-api/codex"
+ \\# auth = "openai_codex"
+ \\#
+ \\# [providers.codex.extra_headers]
+ \\# OpenAI-Beta = "responses=experimental"
+ \\# originator = "codex_cli_rs"
+ \\#
+ \\# [auth.openai_codex]
+ \\# type = "oauth_device"
+ \\# dialect = "codex"
+ \\# client_id = "app_EMoamEEZ73f0CkXaXp7hrann"
+ \\# device_code_url = "https://auth.openai.com/api/accounts/deviceauth/usercode"
+ \\# device_poll_url = "https://auth.openai.com/api/accounts/deviceauth/token"
+ \\# verification_url = "https://auth.openai.com/codex/device"
+ \\# token_url = "https://auth.openai.com/oauth/token"
+ \\# account_id_jwt_claim = "https://api.openai.com/auth"
+ \\
\\# Tool/extension availability (glob patterns). Empty allow = allow all.
\\# [tools]
\\# allow = ["std.*"]
diff --git a/src/tui_selectors.zig b/src/tui_selectors.zig
index a37acb8..590d321 100644
--- a/src/tui_selectors.zig
+++ b/src/tui_selectors.zig
@@ -48,7 +48,10 @@ pub const ReasoningOption = struct {
detail: []const u8,
value: Value,
- pub const Value = union(APIStyle) {
+ /// Two payload families, decoupled from `APIStyle`: `openai_chat` carries
+ /// a `reasoning` effort (shared by the `openai_chat` and `openai_responses`
+ /// styles), `anthropic_messages` carries a thinking selection.
+ pub const Value = union(enum) {
/// OpenAI `reasoning` enum value.
openai_chat: ReasoningEffort,
/// Anthropic thinking selection: either disabled, or adaptive at an
@@ -85,7 +88,8 @@ pub const ReasoningOption = struct {
/// The reasoning options offered for a provider config's API style.
pub fn forStyle(style: APIStyle) []const ReasoningOption {
return switch (style) {
- .openai_chat => &openai,
+ // The Responses style takes the same reasoning-effort options.
+ .openai_chat, .openai_responses => &openai,
.anthropic_messages => &anthropic,
};
}
@@ -95,6 +99,7 @@ pub const ReasoningOption = struct {
switch (self.value) {
.openai_chat => |r| switch (cfg.*) {
.openai_chat => |*c| c.reasoning = r,
+ .openai_responses => |*c| c.reasoning = r,
.anthropic_messages => {},
},
.anthropic_messages => |sel| switch (cfg.*) {
@@ -105,7 +110,7 @@ pub const ReasoningOption = struct {
c.effort = e;
},
},
- .openai_chat => {},
+ .openai_chat, .openai_responses => {},
},
}
}
@@ -114,7 +119,8 @@ pub const ReasoningOption = struct {
/// preselect the picker on the active value).
pub fn matches(self: ReasoningOption, cfg: ProviderConfig) bool {
switch (self.value) {
- .openai_chat => |r| return cfg == .openai_chat and cfg.openai_chat.reasoning == r,
+ .openai_chat => |r| return (cfg == .openai_chat and cfg.openai_chat.reasoning == r) or
+ (cfg == .openai_responses and cfg.openai_responses.reasoning == r),
.anthropic_messages => |sel| {
if (cfg != .anthropic_messages) return false;
const c = cfg.anthropic_messages;
@@ -167,7 +173,7 @@ pub fn adaptiveFallback(cfg: *ProviderConfig) bool {
c.thinking_budget_tokens = budget;
return true;
},
- .openai_chat => return false,
+ .openai_chat, .openai_responses => return false,
}
}