diff options
| author | t <t@tjp.lol> | 2026-06-13 12:22:18 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-15 15:08:32 -0600 |
| commit | eb19fd812e15de5a6930daf2fcd0b2b0757687c7 (patch) | |
| tree | ace85c641e48836fe45b289141213fe8984ccb52 | |
| parent | 9f983c5d0ecc1c12f15eadf1add123e24995b660 (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.zig | 8 | ||||
| -rw-r--r-- | libpanto/src/config.zig | 24 | ||||
| -rw-r--r-- | libpanto/src/openai_responses_json.zig | 582 | ||||
| -rw-r--r-- | libpanto/src/provider.zig | 12 | ||||
| -rw-r--r-- | libpanto/src/provider_openai_responses.zig | 656 | ||||
| -rw-r--r-- | libpanto/src/public.zig | 5 | ||||
| -rw-r--r-- | src/auth_manager.zig | 5 | ||||
| -rw-r--r-- | src/config_file.zig | 8 | ||||
| -rw-r--r-- | src/luarocks_runtime.zig | 24 | ||||
| -rw-r--r-- | src/tui_selectors.zig | 16 |
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, } } |
