diff options
| author | T <t@tjp.lol> | 2026-05-25 13:24:02 -0700 |
|---|---|---|
| committer | T <t@tjp.lol> | 2026-05-25 15:27:11 -0700 |
| commit | df2edee86eec2a8deb0ad57b5d20552199c12b65 (patch) | |
| tree | c1e962e9b0bdf25572929e3efedc3b1915be853f /libpanto/src/provider_openai_chat.zig | |
| parent | e8272efd9f71ad27a0e62b6b3fa3d13e99a6736f (diff) | |
phase 1 done
Diffstat (limited to 'libpanto/src/provider_openai_chat.zig')
| -rw-r--r-- | libpanto/src/provider_openai_chat.zig | 443 |
1 files changed, 443 insertions, 0 deletions
diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig new file mode 100644 index 0000000..e1b9f81 --- /dev/null +++ b/libpanto/src/provider_openai_chat.zig @@ -0,0 +1,443 @@ +//! OpenAI Chat Completions streaming provider. +//! +//! Wire format reference: https://platform.openai.com/docs/api-reference/chat/streaming +//! +//! Responsibilities: +//! - Convert `Conversation` → request JSON (delegated to openai_chat_json.zig) +//! - POST to `{base_url}/chat/completions` with `stream: true` +//! - Read the chunked body, feed bytes through SSEParser +//! - Parse each event payload, drive the block boundary state machine, +//! and emit Receiver callbacks +//! - Assemble the final Message and emit onMessageComplete + +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 sse_mod = @import("sse.zig"); +const json_mod = @import("openai_chat_json.zig"); +const config_mod = @import("config.zig"); + +/// Active streaming block type tracked by the state machine. Mirrors the +/// `ContentBlock` union variants but adds `.none` for "no block open yet". +const ActiveBlock = enum { none, text, thinking }; + +pub const OpenAIChatProvider = struct { + allocator: Allocator, + io: Io, + config: config_mod.Config, + http_client: http.Client, + + pub fn init(allocator: Allocator, io: Io, cfg: config_mod.Config) OpenAIChatProvider { + return .{ + .allocator = allocator, + .io = io, + .config = cfg, + .http_client = .{ .allocator = allocator, .io = io }, + }; + } + + pub fn deinit(self: *OpenAIChatProvider) void { + self.http_client.deinit(); + } + + /// Return a `Provider` interface bound to this concrete provider. + pub fn provider(self: *OpenAIChatProvider) provider_mod.Provider { + return .{ .ptr = self, .vtable = &vtable }; + } + + const vtable: provider_mod.ProviderVTable = .{ + .streamStep = vtableStreamStep, + .deinit = vtableDeinit, + }; + + fn vtableStreamStep( + ptr: *anyopaque, + conv: *conversation.Conversation, + receiver: *provider_mod.Receiver, + ) anyerror!void { + const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr)); + return self.streamStep(conv, receiver); + } + + fn vtableDeinit(ptr: *anyopaque) void { + const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr)); + self.deinit(); + } + + pub fn streamStep( + self: *OpenAIChatProvider, + conv: *conversation.Conversation, + receiver: *provider_mod.Receiver, + ) !void { + // Outer wrapper guarantees `onError` is called exactly once if + // anything fails — whether the receiver, the HTTP transport, or the + // SSE/JSON parsers. The inner `streamStepInner` does the real work. + self.streamStepInner(conv, receiver) catch |err| { + receiver.onError(err); + return err; + }; + } + + fn streamStepInner( + self: *OpenAIChatProvider, + conv: *conversation.Conversation, + receiver: *provider_mod.Receiver, + ) !void { + // Build URL: "{base_url}/chat/completions" + const url = try std.fmt.allocPrint( + self.allocator, + "{s}/chat/completions", + .{self.config.base_url}, + ); + defer self.allocator.free(url); + + const uri = try Uri.parse(url); + + // Build the request body. + const body = try json_mod.serializeRequest(self.allocator, &self.config, conv); + defer self.allocator.free(body); + + // Auth header + const auth_value = try std.fmt.allocPrint( + self.allocator, + "Bearer {s}", + .{self.config.api_key}, + ); + defer self.allocator.free(auth_value); + + const extra_headers = [_]http.Header{ + .{ .name = "content-type", .value = "application/json" }, + .{ .name = "accept", .value = "text/event-stream" }, + .{ .name = "authorization", .value = auth_value }, + }; + + // Open the request. We can't use `fetch()` because it buffers the + // response; we want to stream the body as it arrives. + var req = try self.http_client.request(.POST, uri, .{ + .extra_headers = &extra_headers, + .keep_alive = false, + .redirect_behavior = .not_allowed, + }); + defer req.deinit(); + + req.transfer_encoding = .{ .content_length = body.len }; + + var send_buf: [4096]u8 = undefined; + var bw = try req.sendBodyUnflushed(&send_buf); + try bw.writer.writeAll(body); + try bw.end(); + try req.connection.?.flush(); + + // Receive response headers. + var redirect_buf: [1024]u8 = undefined; + var response = try req.receiveHead(&redirect_buf); + + if (@intFromEnum(response.head.status) >= 400) { + // Drain body for diagnostics. + var transfer_buf: [4096]u8 = undefined; + const body_reader = response.reader(&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; + } + std.log.err("openai_chat HTTP {d}: {s}", .{ + @intFromEnum(response.head.status), + err_buf.items, + }); + return error.HttpError; + } + + // Stream the body through the SSE parser and event handler. + var transfer_buf: [4096]u8 = undefined; + const body_reader = response.reader(&transfer_buf); + + var parser = sse_mod.SSEParser.init(self.allocator); + defer parser.deinit(); + + var state: StreamState = .init(self.allocator); + defer state.deinit(); + + var chunk: [4096]u8 = undefined; + while (true) { + const n = try body_reader.readSliceShort(&chunk); + if (n == 0) break; + + const events = try parser.feed(chunk[0..n]); + defer parser.freeEvents(events); + + for (events) |ev_payload| { + if (std.mem.eql(u8, ev_payload, "[DONE]")) { + try state.finalize(receiver, conv); + return; + } + try handleEvent(self.allocator, ev_payload, &state, receiver); + if (state.end_of_stream) { + try state.finalize(receiver, conv); + return; + } + } + } + + // Stream ended without [DONE] or finish_reason. Finalize anyway. + try state.finalize(receiver, conv); + } +}; + +/// State maintained across the streaming response: which block is currently +/// being assembled, accumulated content, and the assistant message being +/// built up for the final `onMessageComplete` callback. +const StreamState = struct { + allocator: Allocator, + started: bool = false, + /// Set when the wire stream signals end-of-turn (finish_reason or [DONE]). + /// Tells the outer read loop to stop pulling more events. + end_of_stream: bool = false, + /// Set once `finalize` has run, to make it idempotent. + finalized: bool = false, + active: ActiveBlock = .none, + block_index: usize = 0, + + /// Buffer for the currently-streaming block's content. + /// Owned by this state until the block is completed, at which point + /// ownership transfers to the assembled Message. + current_buf: conversation.TextualBlock = .empty, + + /// Assembled blocks for the final message. + blocks: std.ArrayList(conversation.ContentBlock) = .empty, + + 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); + } + + /// Close the active block (if any) and emit onBlockComplete. + /// Ownership of `current_buf` transfers into the appended block. + fn closeActive(self: *StreamState, receiver: *provider_mod.Receiver) !void { + if (self.active == .none) return; + + const block: conversation.ContentBlock = switch (self.active) { + .text => .{ .Text = self.current_buf }, + .thinking => .{ .Thinking = self.current_buf }, + .none => unreachable, + }; + // The buffer ownership has moved into `block`; replace with empty. + self.current_buf = .empty; + + try self.blocks.append(self.allocator, block); + try receiver.onBlockComplete(self.block_index, self.blocks.items[self.blocks.items.len - 1]); + + self.active = .none; + } + + /// Open a new block of the given type, possibly closing a prior block. + fn openBlock( + self: *StreamState, + new_active: ActiveBlock, + receiver: *provider_mod.Receiver, + ) !void { + if (self.active == new_active) return; + if (self.active != .none) { + try self.closeActive(receiver); + self.block_index += 1; + } + self.active = new_active; + const block_type: provider_mod.ContentBlockType = switch (new_active) { + .text => .Text, + .thinking => .Thinking, + .none => unreachable, + }; + try receiver.onBlockStart(block_type, self.block_index, null); + } + + fn appendDelta( + self: *StreamState, + receiver: *provider_mod.Receiver, + delta: []const u8, + ) !void { + try self.current_buf.appendSlice(self.allocator, delta); + try receiver.onContentDelta(self.block_index, delta); + } + + /// End the stream: close any open block and emit onMessageComplete. + /// Ownership of the assembled blocks transfers into the conversation. + fn finalize( + self: *StreamState, + receiver: *provider_mod.Receiver, + conv: *conversation.Conversation, + ) !void { + if (self.finalized) return; + self.finalized = true; + + try self.closeActive(receiver); + + // Move blocks into a fresh conversation message. + const moved_blocks = try self.blocks.toOwnedSlice(self.allocator); + defer self.allocator.free(moved_blocks); + + try conv.addAssistantMessage(moved_blocks); + + // The conversation now owns the block buffers. Build a Message view + // for the callback (borrowed from the conversation). + const msg = conv.messages.items[conv.messages.items.len - 1]; + try receiver.onMessageComplete(msg); + } +}; + +fn handleEvent( + allocator: Allocator, + payload: []const u8, + state: *StreamState, + receiver: *provider_mod.Receiver, +) !void { + var parsed = try json_mod.parseStreamEvent(allocator, payload); + defer parsed.deinit(); + const d = parsed.delta; + + if (!state.started and d.role != null) { + state.started = true; + try receiver.onMessageStart(.assistant); + } + + if (d.reasoning_content) |rc| { + if (!state.started) { + state.started = true; + try receiver.onMessageStart(.assistant); + } + try state.openBlock(.thinking, receiver); + try state.appendDelta(receiver, rc); + } + + if (d.content) |c| { + if (!state.started) { + state.started = true; + try receiver.onMessageStart(.assistant); + } + try state.openBlock(.text, receiver); + try state.appendDelta(receiver, c); + } + + if (d.finish_reason) |_| { + state.end_of_stream = true; + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +const testing = std.testing; + +/// A no-op Receiver that drops every callback. Useful when the test cares +/// about post-stream conversation state rather than callback observability. +const NoopReceiver = struct { + fn make() provider_mod.Receiver { + return .{ .ptr = @constCast(@ptrCast(&dummy)), .vtable = &vt }; + } + var dummy: u8 = 0; + const vt: provider_mod.ReceiverVTable = .{ + .onMessageStart = noopMsgStart, + .onBlockStart = noopBlockStart, + .onContentDelta = noopDelta, + .onBlockComplete = noopBlockComplete, + .onMessageComplete = noopMsgComplete, + .onError = noopErr, + }; + fn noopMsgStart(_: *anyopaque, _: conversation.MessageRole) anyerror!void {} + fn noopBlockStart(_: *anyopaque, _: provider_mod.ContentBlockType, _: usize, _: ?provider_mod.BlockMeta) anyerror!void {} + fn noopDelta(_: *anyopaque, _: usize, _: []const u8) anyerror!void {} + fn noopBlockComplete(_: *anyopaque, _: usize, _: conversation.ContentBlock) anyerror!void {} + fn noopMsgComplete(_: *anyopaque, _: conversation.Message) anyerror!void {} + fn noopErr(_: *anyopaque, _: anyerror) void {} +}; + +/// Feed a sequence of SSE event payloads through the state machine as if +/// they had been delivered by the wire, finalizing into `conv`. +fn runStreamedTurn( + allocator: Allocator, + conv: *conversation.Conversation, + receiver: *provider_mod.Receiver, + events: []const []const u8, +) !void { + var state: StreamState = .init(allocator); + defer state.deinit(); + + for (events) |payload| { + if (std.mem.eql(u8, payload, "[DONE]")) break; + try handleEvent(allocator, payload, &state, receiver); + if (state.end_of_stream) break; + } + try state.finalize(receiver, conv); +} + +test "two streamed turns persist assistant replies in the conversation" { + // Regression test for the bug where `finish_reason` arrived before + // `[DONE]` and `finalize` early-returned without appending the assistant + // message, so follow-up turns were sent without prior responses. + + const allocator = testing.allocator; + + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + + try conv.addSystemMessage("You are a helpful assistant."); + try conv.addUserMessage("hello!"); + + var recv = NoopReceiver.make(); + + const turn1 = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"content":"Hello! "}}]} + , + \\{"choices":[{"delta":{"content":"How can I help you today?"}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"stop"}]} + , + "[DONE]", + }; + try runStreamedTurn(allocator, &conv, &recv, &turn1); + + try testing.expectEqual(@as(usize, 3), conv.messages.items.len); + try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[2].role); + try testing.expectEqualStrings( + "Hello! How can I help you today?", + conv.messages.items[2].content.items[0].Text.items, + ); + + // Second user turn: the assistant must still see its prior response. + try conv.addUserMessage("how did you respond to my greeting just now?"); + + const turn2 = [_][]const u8{ + \\{"choices":[{"delta":{"role":"assistant"}}]} + , + \\{"choices":[{"delta":{"content":"I replied: \"Hello! How can I help you today?\""}}]} + , + \\{"choices":[{"delta":{},"finish_reason":"stop"}]} + , + "[DONE]", + }; + try runStreamedTurn(allocator, &conv, &recv, &turn2); + + // System + user + assistant + user + assistant = 5 messages. + try testing.expectEqual(@as(usize, 5), conv.messages.items.len); + try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[4].role); + try testing.expectEqualStrings( + "I replied: \"Hello! How can I help you today?\"", + conv.messages.items[4].content.items[0].Text.items, + ); +} |
