summaryrefslogtreecommitdiff
path: root/libpanto/src/provider_openai_chat.zig
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-01 08:21:00 -0600
committert <t@tjp.lol>2026-06-01 11:20:56 -0600
commit1beefefc69beee214430eb5bd2528a4f5692d2a8 (patch)
treea459dbde5da17babe7d872999341d2006ebfe02e /libpanto/src/provider_openai_chat.zig
parent5c016cfdbcb122e2b4f0496ef946642d68953c4b (diff)
Rename system extension layer to base for clarity
Replace all references to the "system" layer with "base" to better reflect its role as the foundational extension/tool layer in panto's hierarchy. The layer hierarchy now consistently uses: project > user > base. Includes: - Update agent README and build.zig documentation - Refactor tool registry and config module to support layered lookups - Add TestHarness abstraction for cleaner test setup - Improve JSON serialization with wire-encoded tool names - Add glob pattern matching for tool/extension discovery
Diffstat (limited to 'libpanto/src/provider_openai_chat.zig')
-rw-r--r--libpanto/src/provider_openai_chat.zig114
1 files changed, 62 insertions, 52 deletions
diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig
index fcbc0bb..d70eb30 100644
--- a/libpanto/src/provider_openai_chat.zig
+++ b/libpanto/src/provider_openai_chat.zig
@@ -21,63 +21,34 @@ 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");
+const tool_registry_mod = @import("tool_registry.zig");
+
+/// Decode a wire tool name (`__` -> `.`) in place within an assembled
+/// name buffer. Decoding only ever shrinks the buffer (reads stay ahead
+/// of writes), so aliasing src/dst is safe; we then truncate to the
+/// decoded length. Unambiguous because internal names never contain a
+/// literal `__` (enforced at registration).
+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;
+}
/// 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, tool_use };
-pub const OpenAIChatProvider = struct {
+/// A single OpenAI Chat streaming request. Transient: constructed per
+/// `streamStep`, holds only borrowed state (allocator, io, the global HTTP
+/// client, and the active config). Carries nothing across requests, so it
+/// is created inline by the free `streamStep` entry point below.
+pub const OpenAIChatRequest = struct {
allocator: Allocator,
io: Io,
- config: config_mod.OpenAIChatConfig,
- http_client: http.Client,
-
- pub fn init(allocator: Allocator, io: Io, cfg: config_mod.OpenAIChatConfig) 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,
- tools: *const provider_mod.ToolRegistry,
- receiver: *provider_mod.Receiver,
- ) anyerror!void {
- const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr));
- return self.streamStep(conv, tools, receiver);
- }
-
- /// Called via the `Provider` interface. Tears down the impl AND frees
- /// its heap allocation, since `Provider.init` is the one that allocated
- /// it. Direct stack-allocated users (tests, embedders) call `deinit`
- /// themselves and never hit this path.
- fn vtableDeinit(ptr: *anyopaque) void {
- const self: *OpenAIChatProvider = @ptrCast(@alignCast(ptr));
- const allocator = self.allocator;
- self.deinit();
- allocator.destroy(self);
- }
+ config: *const config_mod.OpenAIChatConfig,
+ http_client: *http.Client,
pub fn streamStep(
- self: *OpenAIChatProvider,
+ self: *OpenAIChatRequest,
conv: *conversation.Conversation,
tools: *const provider_mod.ToolRegistry,
receiver: *provider_mod.Receiver,
@@ -92,7 +63,7 @@ pub const OpenAIChatProvider = struct {
}
fn streamStepInner(
- self: *OpenAIChatProvider,
+ self: *OpenAIChatRequest,
conv: *conversation.Conversation,
tools: *const provider_mod.ToolRegistry,
receiver: *provider_mod.Receiver,
@@ -108,7 +79,7 @@ pub const OpenAIChatProvider = struct {
const uri = try Uri.parse(url);
// Build the request body.
- const body = try json_mod.serializeRequest(self.allocator, &self.config, conv, tools);
+ const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools);
defer self.allocator.free(body);
// Auth header
@@ -490,6 +461,12 @@ const StreamState = struct {
return;
}
+ // The model echoes the wire-encoded tool name (`__` for `.`).
+ // Decode in place now that the full name is assembled, so the
+ // conversation, receiver callbacks, and dispatch all see the
+ // internal (dotted) name. Decoding never grows the buffer.
+ decodeNameInPlace(&tu.name_buf);
+
// If no arguments ever arrived, we haven't emitted onBlockStart
// yet — do it now so the receiver sees a balanced start/complete.
try self.emitStartIfNeeded(receiver, &tu);
@@ -640,7 +617,7 @@ const testing = std.testing;
/// about post-stream conversation state rather than callback observability.
const NoopReceiver = struct {
fn make() provider_mod.Receiver {
- return .{ .ptr = @constCast(@ptrCast(&dummy)), .vtable = &vt };
+ return .{ .ptr = @ptrCast(@constCast(&dummy)), .vtable = &vt };
}
var dummy: u8 = 0;
const vt: provider_mod.ReceiverVTable = .{
@@ -676,7 +653,7 @@ fn runStreamedTurn(
if (std.mem.eql(u8, payload, "[DONE]")) break;
// Process every chunk through to [DONE], including the
// post-finish_reason usage chunk. Mirrors the production loop
- // in OpenAIChatProvider.streamStep.
+ // in OpenAIChatRequest.streamStep.
try handleEvent(allocator, payload, &state, receiver);
}
try state.finalize(receiver, conv);
@@ -847,6 +824,39 @@ test "fragmented tool_call id and name are reassembled" {
try testing.expectEqualStrings("{\"host\":\"a.com\"}", tu.input.items);
}
+test "inbound wire tool name is decoded to dotted form (even split across __)" {
+ // The model echoes the wire name it was given (`std__read`). It is
+ // decoded to the internal `std.read` for the conversation/session/
+ // dispatch. The decode happens after full assembly, so a `__` split
+ // across two deltas (`std_` + `_read`) decodes correctly.
+ const allocator = testing.allocator;
+
+ var conv = conversation.Conversation.init(allocator);
+ defer conv.deinit();
+ try conv.addUserMessage("read a file");
+
+ var recv = NoopReceiver.make();
+
+ const events = [_][]const u8{
+ \\{"choices":[{"delta":{"role":"assistant"}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","type":"function","function":{"name":"std_"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"_read"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}}]}}]}
+ ,
+ \\{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}
+ ,
+ "[DONE]",
+ };
+
+ try runStreamedTurn(allocator, &conv, &recv, &events);
+
+ const tu = conv.messages.items[1].content.items[0].ToolUse;
+ try testing.expectEqualStrings("std.read", tu.name);
+}
+
/// A Receiver that records the sequence of callback events as compact
/// strings. Useful for asserting per-block start/complete ordering.
const RecordingReceiver = struct {