summaryrefslogtreecommitdiff
path: root/libpanto/src/provider_openai_chat.zig
diff options
context:
space:
mode:
authorT <t@tjp.lol>2026-05-25 13:24:02 -0700
committerT <t@tjp.lol>2026-05-25 15:27:11 -0700
commitdf2edee86eec2a8deb0ad57b5d20552199c12b65 (patch)
treec1e962e9b0bdf25572929e3efedc3b1915be853f /libpanto/src/provider_openai_chat.zig
parente8272efd9f71ad27a0e62b6b3fa3d13e99a6736f (diff)
phase 1 done
Diffstat (limited to 'libpanto/src/provider_openai_chat.zig')
-rw-r--r--libpanto/src/provider_openai_chat.zig443
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,
+ );
+}