diff options
Diffstat (limited to 'libpanto/src/provider_anthropic_messages.zig')
| -rw-r--r-- | libpanto/src/provider_anthropic_messages.zig | 1267 |
1 files changed, 0 insertions, 1267 deletions
diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig deleted file mode 100644 index 955991a..0000000 --- a/libpanto/src/provider_anthropic_messages.zig +++ /dev/null @@ -1,1267 +0,0 @@ -//! Anthropic Messages API streaming provider. -//! -//! Wire format reference: -//! https://platform.claude.com/docs/en/build-with-claude/streaming -//! -//! Responsibilities: -//! - Convert `Conversation` → request JSON (delegated to anthropic_messages_json.zig) -//! - POST to `{base_url}/v1/messages` with `stream: true` -//! (the provider owns the current `/v1` suffix; a future wire revision -//! would add a new `anthropic_messages_v2` API style rather than guessing -//! from the configured base URL) -//! - Read the chunked body, feed bytes through SSEParser -//! - Parse each event payload, drive a thin assembly loop, and emit Receiver -//! callbacks. Anthropic gives us explicit block boundaries, so no -//! state-machine inference is needed. -//! - 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 stream_mod = @import("stream.zig"); -const sse_mod = @import("sse.zig"); -const json_mod = @import("anthropic_messages_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; - -/// A single Anthropic Messages streaming request. Transient: constructed -/// per `streamStep`, holds only borrowed state (allocator, io, the global -/// HTTP client, and the active config). Carries nothing across requests. -pub const AnthropicMessagesRequest = struct { - allocator: Allocator, - io: Io, - config: *const config_mod.AnthropicMessagesConfig, - http_client: *http.Client, - /// Optional diagnostic side-channel; see `OpenAIChatRequest.diag`. - diag: ?*provider_mod.ProviderDiagnostic = null, - - /// Open the streaming HTTP request and return a heap-allocated resumable - /// response. Reads response headers (classifying any >=400 status) but - /// does not pump the body — that happens lazily in - /// `ResumableResponse.produce`. On success the caller owns the returned - /// `*ResumableResponse` and must `deinit` it. - pub fn open( - self: *AnthropicMessagesRequest, - 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), - }; - rr.state.signature_origin = try conversation.SignatureOrigin.init( - self.allocator, - .anthropic_messages, - self.config.base_url, - self.config.model, - ); - errdefer { - rr.parser.deinit(); - rr.state.deinit(); - } - - const trimmed_base = std.mem.trim(u8, self.config.base_url, "/"); - const url = try std.fmt.allocPrint( - self.allocator, - "{s}/v1/messages", - .{trimmed_base}, - ); - 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); - std.log.debug("anthropic_messages => {s}", .{body}); - - // Build headers. Standard Anthropic-compatible backends use - // `x-api-key`. OAuth-backed Anthropic-compatible providers (e.g. - // Copilot) opt into `Authorization: Bearer ...` via - // `config.use_bearer_auth`, which is derived from the configured auth - // family rather than guessed from the URL/headers. The four base - // headers are always present; the interleaved-thinking beta header is - // added only when the config explicitly requests manual extended - // thinking with interleaving. It is intentionally NOT sent for - // `.adaptive` (interleaving is automatic there and the header causes - // 400s on some backends) or `.disabled`. - const use_bearer_auth = self.config.use_bearer_auth; - const auth_value = if (use_bearer_auth) - try std.fmt.allocPrint( - self.allocator, - "Bearer {s}", - .{self.config.api_key}, - ) - else - ""; - defer if (use_bearer_auth) self.allocator.free(auth_value); - const auth_header: http.Header = if (use_bearer_auth) - .{ .name = "authorization", .value = auth_value } - else - .{ .name = "x-api-key", .value = self.config.api_key }; - var headers_buf: [5]http.Header = .{ - .{ .name = "content-type", .value = "application/json" }, - .{ .name = "accept", .value = "text/event-stream" }, - auth_header, - .{ .name = "anthropic-version", .value = self.config.api_version }, - undefined, // slot reserved for the optional beta header - }; - const send_interleaved = self.config.thinking == .enabled and - self.config.thinking_interleaved; - if (send_interleaved) { - headers_buf[4] = .{ - .name = "anthropic-beta", - .value = "interleaved-thinking-2025-05-14", - }; - } - const base_headers = headers_buf[0..if (send_interleaved) @as(usize, 5) else @as(usize, 4)]; - // Merge any provider `extra_headers` onto the base set. Freed at the - // end of `open` — after the request body has been flushed. - const extra_headers = try provider_mod.mergeHeaders( - self.allocator, - base_headers, - self.config.extra_headers, - ); - defer self.allocator.free(extra_headers); - - rr.response = try provider_mod.sendRequest(self.http_client, uri, extra_headers, body, &rr.req); - rr.req_open = true; - errdefer { - rr.req.deinit(); - rr.req_open = false; - } - - // A >=400 status maps to a retryable/terminal provider error. Anthropic - // rejects oversized requests with HTTP 400 + "prompt is too long", - // which `classifyErrorResponse` maps to ContextOverflow (compact+retry). - if (@intFromEnum(rr.response.head.status) >= 400) { - return provider_mod.classifyErrorResponse(self.allocator, &rr.response, &rr.transfer_buf, self.diag, "anthropic_messages"); - } - - rr.body_reader = rr.response.reader(&rr.transfer_buf); - return rr; - } -}; - -/// A resumable Anthropic Messages streaming response. Owns the pinned HTTP -/// request/response, the body reader's transfer buffer, the `SSEParser`, and -/// the block-assembly `StreamState`. Must be heap-allocated and never moved: -/// `body_reader` borrows `&self.response`. -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; - - /// Wrap this response in the provider-agnostic `ProviderStream` the agent - /// loop drives. - 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 produceVT(ptr: *anyopaque, out: *EventQueue) anyerror!ProduceStatus { - const self: *ResumableResponse = @ptrCast(@alignCast(ptr)); - return self.produce(out); - } - - fn lastErrorVT(ptr: *anyopaque) ?[]const u8 { - const self: *ResumableResponse = @ptrCast(@alignCast(ptr)); - return self.state.stream_error_message; - } - - 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); - } - - /// Pump the response: read one chunk, feed it through the SSE parser, and - /// decode each SSE event into zero or more `Event`s appended to `out`. - /// Returns `.more` if the caller should pump again, or - /// `.response_complete` once `message_stop` (or EOF) is reached and the - /// assistant message has been committed + a final `message_complete` - /// pushed. - 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) { - // Stream ended without an explicit message_stop. Finalize anyway. - error.EndOfStream => { - try self.finishStream(out); - return .response_complete; - }, - // Transport failure before the message completed: retryable. - 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("anthropic_messages <= {s}", .{ev_payload}); - try handleEvent(self.allocator, ev_payload, &self.state, out); - if (self.state.end_of_stream) { - 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); - } -}; - -/// State maintained across the streaming response. -/// -/// Anthropic gives us explicit block boundaries (`content_block_start` / -/// `content_block_stop`), so we don't need to infer transitions like -/// `provider_openai_chat` does. We just track the currently-open block. -const StreamState = struct { - allocator: Allocator, - started: bool = false, - end_of_stream: bool = false, - finalized: bool = false, - - /// The block currently being assembled (if any). - active: ?ActiveBlock = null, - - /// Assembled blocks for the final message, in stream order. - blocks: std.ArrayList(conversation.ContentBlock) = .empty, - - /// Accumulated token counts. Anthropic reports the input-side counts - /// on `message_start.usage` and the final `output_tokens` (plus - /// possibly updated input-side counts) on `message_delta.usage`. - /// `usage_seen` distinguishes "genuinely all zero" from "never - /// reported" — the former stamps a real `Usage` on - /// `onMessageComplete`, the latter stamps `null`. - usage: provider_mod.Usage = .{}, - usage_seen: bool = false, - signature_origin: ?conversation.SignatureOrigin = null, - stop_reason: ?[]u8 = null, - /// Owned, human-readable description of a mid-stream `error` event - /// (e.g. `"overloaded_error: Overloaded"`), surfaced to the agent via - /// `ProviderStream.lastError` so the retry notice can show *why*. - stream_error_message: ?[]u8 = null, - - const ActiveBlock = struct { - /// Index reported on the wire (Anthropic's content-array index). - wire_index: usize, - kind: BlockKind, - text_buf: conversation.TextualBlock = .empty, - signature: ?[]const u8 = null, - /// Populated for `.tool_use` blocks. Owned by this state until the - /// block closes, at which point ownership transfers to the - /// ToolUseBlock. - tool_id: ?[]u8 = null, - tool_name: ?[]u8 = null, - }; - - const BlockKind = enum { text, thinking, tool_use, unsupported }; - - fn init(allocator: Allocator) StreamState { - return .{ .allocator = allocator }; - } - - fn deinit(self: *StreamState) void { - if (self.active) |*a| { - a.text_buf.deinit(self.allocator); - if (a.signature) |sig| self.allocator.free(sig); - if (a.tool_id) |s| self.allocator.free(s); - if (a.tool_name) |s| self.allocator.free(s); - } - for (self.blocks.items) |*b| b.deinit(self.allocator); - self.blocks.deinit(self.allocator); - if (self.signature_origin) |*o| o.deinit(self.allocator); - if (self.stop_reason) |s| self.allocator.free(s); - if (self.stream_error_message) |s| self.allocator.free(s); - } - - fn ensureStarted(self: *StreamState, out: *EventQueue) !void { - if (self.started) return; - self.started = true; - try out.push(.{ .message_start = .assistant }); - } - - /// Merge a wire-level usage snapshot into the accumulated counts. - /// Missing fields mean "unchanged," not "reset to zero." Marks - /// `usage_seen` so `finalize` delivers a non-null `Usage` to - /// `onMessageComplete`. - fn mergeUsage(self: *StreamState, partial: json_mod.StreamUsage) void { - if (partial.input_tokens) |v| self.usage.input = v; - if (partial.output_tokens) |v| self.usage.output = v; - if (partial.cache_creation_input_tokens) |v| self.usage.cache_write = v; - if (partial.cache_read_input_tokens) |v| self.usage.cache_read = v; - if (partial.input_tokens != null or partial.output_tokens != null or - partial.cache_creation_input_tokens != null or partial.cache_read_input_tokens != null) - { - self.usage_seen = true; - } - } - - fn openBlock( - self: *StreamState, - out: *EventQueue, - wire_index: usize, - kind: BlockKind, - tool_id: ?[]const u8, - tool_name: ?[]const u8, - ) !void { - // Defensive: if a prior block didn't get an explicit stop, drop it. - if (self.active != null) { - self.discardActive(); - } - var ab: ActiveBlock = .{ - .wire_index = wire_index, - .kind = kind, - }; - // For tool_use blocks, capture the identity fields. Anthropic - // delivers both whole on content_block_start. The wire name is - // encoded (`__` for `.`); decode it here so everything downstream - // — onToolDetails, the stored ContentBlock, session logs, and - // dispatch — sees the internal (dotted) name. The decoded form is - // never longer than the wire form. - if (kind == .tool_use) { - if (tool_id) |id| ab.tool_id = try self.allocator.dupe(u8, id); - if (tool_name) |n| { - // Decode `__` -> `.` into an exact-size owned buffer so the - // stored slice is freeable as a whole allocation. - const owned = try self.allocator.alloc(u8, n.len); - errdefer self.allocator.free(owned); - const decoded = tool_registry_mod.decodeName(owned, n); - if (decoded.len == n.len) { - ab.tool_name = owned; - } else { - ab.tool_name = try self.allocator.realloc(owned, decoded.len); - } - } - } - self.active = ab; - const block_type: ?provider_mod.ContentBlockType = switch (kind) { - .text => .Text, - .thinking => .Thinking, - .tool_use => .ToolUse, - .unsupported => null, - }; - if (block_type) |bt| { - try out.push(.{ .block_start = .{ .block_type = bt, .index = wire_index } }); - // Anthropic delivers tool id+name whole on content_block_start, - // so we can fire tool_details immediately — before any arg - // deltas. If the wire was malformed and either field is - // missing, skip: closeBlock will drop the block defensively. - // id/name are owned by `ab` (stable) but we dupe into the queue - // arena for a uniform borrow lifetime. - if (kind == .tool_use) { - if (ab.tool_id != null and ab.tool_name != null) { - try out.push(.{ .tool_details = .{ - .index = wire_index, - .id = try out.dupeBytes(ab.tool_id.?), - .name = try out.dupeBytes(ab.tool_name.?), - } }); - } - } - } - } - - fn appendTextDelta( - self: *StreamState, - out: *EventQueue, - delta: []const u8, - ) !void { - const a = &(self.active orelse return); - if (a.kind == .unsupported) return; - try a.text_buf.appendSlice(self.allocator, delta); - // Dupe into the queue arena: `delta` borrows the transient SSE/JSON - // payload that `produce` frees before `next()` reads the queue. - try out.push(.{ .content_delta = .{ - .index = a.wire_index, - .delta = try out.dupeBytes(delta), - } }); - } - - /// Append a chunk of the streamed JSON arguments for the active - /// tool_use block. No-op if the active block isn't a tool_use. - fn appendInputJsonDelta( - self: *StreamState, - out: *EventQueue, - delta: []const u8, - ) !void { - const a = &(self.active orelse return); - if (a.kind != .tool_use) return; - try a.text_buf.appendSlice(self.allocator, delta); - try out.push(.{ .content_delta = .{ - .index = a.wire_index, - .delta = try out.dupeBytes(delta), - } }); - } - - fn setSignature(self: *StreamState, sig: []const u8) !void { - const a = &(self.active orelse return); - if (a.signature) |old| self.allocator.free(old); - a.signature = try self.allocator.dupe(u8, sig); - } - - fn setStopReason(self: *StreamState, reason: ?[]const u8) !void { - if (self.stop_reason) |old| self.allocator.free(old); - self.stop_reason = if (reason) |r| try self.allocator.dupe(u8, r) else null; - } - - /// Record a readable description of a mid-stream `error` event, combining - /// the error `kind` and `message` into one owned string (either may be - /// absent). Replaces any previous value. - fn setStreamErrorMessage(self: *StreamState, kind: ?[]const u8, message: ?[]const u8) !void { - if (self.stream_error_message) |old| self.allocator.free(old); - self.stream_error_message = try provider_mod.formatStreamError(self.allocator, kind, message); - } - - /// Close the active block: append it to `blocks` and emit block_complete. - fn closeBlock( - self: *StreamState, - out: *EventQueue, - ) !void { - var a = self.active orelse return; - self.active = null; - - if (a.kind == .unsupported) { - a.text_buf.deinit(self.allocator); - if (a.signature) |sig| self.allocator.free(sig); - if (a.tool_id) |s| self.allocator.free(s); - if (a.tool_name) |s| self.allocator.free(s); - return; - } - // tool_use blocks require both id and name. If either is missing - // (malformed stream), drop the block defensively. - if (a.kind == .tool_use and (a.tool_id == null or a.tool_name == null)) { - a.text_buf.deinit(self.allocator); - if (a.tool_id) |s| self.allocator.free(s); - if (a.tool_name) |s| self.allocator.free(s); - return; - } - - const block: conversation.ContentBlock = switch (a.kind) { - .text => blk: { - if (a.signature) |sig| self.allocator.free(sig); - break :blk .{ .Text = a.text_buf }; - }, - .thinking => .{ .Thinking = .{ - .text = a.text_buf, - .signature = a.signature, - } }, - // An interrupted/malformed tool_use (incomplete or non-object - // input JSON) is preserved as-is. The agent's dispatch path - // detects invalid input and answers it with a synthetic error - // ToolResult in the *following user message* — emitting a - // ToolResult here would wrongly place it in this assistant - // message, which Anthropic rejects. - .tool_use => blk: { - break :blk .{ .ToolUse = .{ - .id = a.tool_id.?, - .name = a.tool_name.?, - .input = a.text_buf, - } }; - }, - .unsupported => unreachable, - }; - - try self.blocks.append(self.allocator, block); - try out.push(.{ .block_complete = .{ - .index = a.wire_index, - .block = self.blocks.items[self.blocks.items.len - 1], - } }); - } - - /// Drop the active block without emitting a completion callback. - /// Used when an unexpected `content_block_start` arrives before the - /// previous block closed. - fn discardActive(self: *StreamState) void { - if (self.active) |*a| { - a.text_buf.deinit(self.allocator); - if (a.signature) |sig| self.allocator.free(sig); - if (a.tool_id) |s| self.allocator.free(s); - if (a.tool_name) |s| self.allocator.free(s); - self.active = null; - } - } - - fn finalize( - self: *StreamState, - out: *EventQueue, - conv: *conversation.Conversation, - ) !void { - if (self.finalized) return; - self.finalized = true; - - if (self.active != null) { - // Preserve an interrupted tool call so the agent can answer it - // with a synthetic error ToolResult instead of invoking it. - try self.closeBlock(out); - } - - const moved_blocks = try self.blocks.toOwnedSlice(self.allocator); - defer self.allocator.free(moved_blocks); - - const usage: ?provider_mod.Usage = if (self.usage_seen) self.usage else null; - if (self.signature_origin) |origin| { - try conversation.setThinkingOrigins( - self.allocator, - moved_blocks, - origin.api_style, - origin.base_url, - origin.model, - ); - } - try conv.addAssistantMessage(moved_blocks, usage); - - const msg = conv.messages.items[conv.messages.items.len - 1]; - try out.push(.{ .message_complete = .{ .message = msg, .usage = usage } }); - } -}; - -fn handleEvent( - allocator: Allocator, - payload: []const u8, - state: *StreamState, - out: *EventQueue, -) !void { - var parsed = try json_mod.parseStreamEvent(allocator, payload); - defer parsed.deinit(); - - switch (parsed.event) { - .message_start => |s| { - try state.ensureStarted(out); - state.mergeUsage(s.usage); - }, - .content_block_start => |s| { - try state.ensureStarted(out); - const kind: StreamState.BlockKind = switch (s.kind) { - .text => .text, - .thinking => .thinking, - .tool_use => .tool_use, - .unknown => .unsupported, - }; - try state.openBlock(out, s.index, kind, s.tool_id, s.tool_name); - }, - .content_block_delta => |d| { - if (d.text_delta) |t| try state.appendTextDelta(out, t); - if (d.thinking_delta) |t| try state.appendTextDelta(out, t); - if (d.signature_delta) |sig| try state.setSignature(sig); - if (d.input_json_delta) |j| try state.appendInputJsonDelta(out, j); - }, - .content_block_stop => |s| { - if (state.active) |a| { - if (a.wire_index == s.index) try state.closeBlock(out); - } - }, - .message_delta => |d| { - try state.setStopReason(d.stop_reason); - state.mergeUsage(d.usage); - }, - .message_stop => { - state.end_of_stream = true; - }, - .ping => {}, - .@"error" => |e| { - if (!@import("builtin").is_test) { - std.log.err("anthropic stream error: {?s}: {?s}", .{ e.kind, e.message }); - } - // Stash a readable description so the agent's retry notice can - // explain *why* the stream failed instead of only showing the - // bare error name. Owned by `state`; freed in `deinit`. - state.setStreamErrorMessage(e.kind, e.message) catch {}; - // Mid-stream error event before the message was committed. Map - // the common overload case to a dedicated retryable error so the - // UI can say "overloaded" rather than "malformed stream"; other - // kinds stay as the generic retryable malformed-stream error. - if (e.kind) |k| { - if (std.mem.eql(u8, k, "overloaded_error")) return error.ProviderOverloaded; - } - return error.ProviderStreamMalformed; - }, - .unknown => { - // Forward-compatible: ignore unknown event types per Anthropic's - // versioning policy. - }, - } -} - -// ----------------------------------------------------------------------------- -// Tests -// ----------------------------------------------------------------------------- - -const testing = std.testing; - -/// Records the decoded pull `Event`s for assertions. Keeps the same typed -/// schema the old RecordingReceiver exposed (message_start / block_start / -/// delta / block_complete / message_complete), so the existing assertions -/// are preserved verbatim. The recorder owns copies of all byte payloads -/// (the queue arena is reset on full drain). -const EventRecorder = struct { - allocator: Allocator, - events: std.ArrayList(Rec) = .empty, - - const Rec = union(enum) { - message_start: conversation.MessageRole, - block_start: struct { - kind: provider_mod.ContentBlockType, - index: usize, - }, - delta: struct { - index: usize, - bytes: []const u8, // owned copy - }, - block_complete: struct { - index: usize, - kind: provider_mod.ContentBlockType, - text: []const u8, // owned copy - signature: ?[]const u8 = null, // owned copy when present - }, - message_complete: ?provider_mod.Usage, - }; - - fn init(allocator: Allocator) EventRecorder { - return .{ .allocator = allocator }; - } - - fn deinit(self: *EventRecorder) void { - for (self.events.items) |ev| { - switch (ev) { - .delta => |d| self.allocator.free(d.bytes), - .block_complete => |b| { - self.allocator.free(b.text); - if (b.signature) |s| self.allocator.free(s); - }, - else => {}, - } - } - self.events.deinit(self.allocator); - } - - /// Translate one pull `Event` into the recorder's schema. Tool identity - /// (`tool_details`) is dropped: the anthropic tests assert tool-use - /// blocks via the ContentBlock in conv after finalize, not via the - /// event stream. Tool-arg `content_delta`s are also dropped here because - /// the old recorder only recorded text/thinking deltas (it routed tool - /// args through a separate path that didn't call onContentDelta in a way - /// these tests observe) — we preserve that by only recording deltas for - /// the currently text/thinking block. Since the recorder can't see block - /// kind from a bare delta, we record every delta; the existing tests - /// only assert delta bytes for text/thinking turns, so this is - /// equivalent for them. - fn record(self: *EventRecorder, ev: Event) !void { - switch (ev) { - .message_start => |role| try self.events.append(self.allocator, .{ .message_start = role }), - .block_start => |b| try self.events.append(self.allocator, .{ .block_start = .{ - .kind = b.block_type, - .index = b.index, - } }), - .content_delta => |d| { - const copy = try self.allocator.dupe(u8, d.delta); - try self.events.append(self.allocator, .{ .delta = .{ .index = d.index, .bytes = copy } }); - }, - .block_complete => |bc| switch (bc.block) { - .Text => |tb| { - const txt = try self.allocator.dupe(u8, tb.items); - try self.events.append(self.allocator, .{ .block_complete = .{ - .kind = .Text, - .index = bc.index, - .text = txt, - } }); - }, - .Thinking => |tb| { - const txt = try self.allocator.dupe(u8, tb.text.items); - const sig = if (tb.signature) |s| try self.allocator.dupe(u8, s) else null; - try self.events.append(self.allocator, .{ .block_complete = .{ - .kind = .Thinking, - .index = bc.index, - .text = txt, - .signature = sig, - } }); - }, - else => {}, - }, - .message_complete => |m| try self.events.append(self.allocator, .{ .message_complete = m.usage }), - 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(); - - for (events) |payload| { - try handleEvent(allocator, payload, &state, &queue); - if (state.end_of_stream) break; - } - try state.finalize(&queue, conv); - - while (queue.pop()) |ev| { - if (rec) |r| try r.record(ev); - } -} - -/// Test helper: append a single-text user message. `addUserMessage` now -/// takes a block slice (symmetric with `addAssistantMessage`); this wraps -/// the common plain-text case the tests below use. -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 "streams a text-only turn end-to-end" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "hello"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - \\{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"claude"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"!"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"message_delta","delta":{"stop_reason":"end_turn"}} - , - \\{"type":"message_stop"} - , - }; - - try runStreamedTurn(allocator, &conv, &rec, &events); - - // Conversation now holds the assistant reply. - try testing.expectEqual(@as(usize, 2), conv.messages.items.len); - try testing.expectEqual(conversation.MessageRole.assistant, conv.messages.items[1].role); - try testing.expectEqual(@as(usize, 1), conv.messages.items[1].content.items.len); - try testing.expectEqualStrings( - "Hello!", - conv.messages.items[1].content.items[0].Text.items, - ); - - // Callback sequence: msg_start, block_start, delta, delta, block_complete, msg_complete. - try testing.expectEqual(@as(usize, 6), rec.events.items.len); - try testing.expectEqual(conversation.MessageRole.assistant, rec.events.items[0].message_start); - try testing.expectEqual(provider_mod.ContentBlockType.Text, rec.events.items[1].block_start.kind); - try testing.expectEqualStrings("Hello", rec.events.items[2].delta.bytes); - try testing.expectEqualStrings("!", rec.events.items[3].delta.bytes); - try testing.expectEqualStrings("Hello!", rec.events.items[4].block_complete.text); - try testing.expect(rec.events.items[5] == .message_complete); - // No usage on the wire — the assertion is structural. - try testing.expect(rec.events.items[5].message_complete == null); -} - -test "anthropic: captures usage from message_start and message_delta on message_complete" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "hi"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - // Initial input-side counts on message_start. - \\{"type":"message_start","message":{"id":"m","type":"message","role":"assistant","content":[],"model":"claude","usage":{"input_tokens":100,"cache_creation_input_tokens":50,"cache_read_input_tokens":200}}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}} - , - \\{"type":"content_block_stop","index":0} - , - // Final output count on message_delta. - \\{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":42}} - , - \\{"type":"message_stop"} - , - }; - - try runStreamedTurn(allocator, &conv, &rec, &events); - - // Find the message_complete event and check its usage payload. - var found: ?provider_mod.Usage = null; - for (rec.events.items) |ev| { - if (ev == .message_complete) found = ev.message_complete; - } - try testing.expect(found != null); - const u = found.?; - try testing.expectEqual(@as(u64, 100), u.input); - try testing.expectEqual(@as(u64, 42), u.output); - try testing.expectEqual(@as(u64, 200), u.cache_read); - try testing.expectEqual(@as(u64, 50), u.cache_write); - try testing.expectEqual(@as(u64, 0), u.reasoning); // Anthropic doesn't split reasoning separately. -} - -test "anthropic: message_complete carries null usage when wire omits it" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "hi"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - \\{"type":"message_start","message":{"id":"m","role":"assistant","content":[],"model":"claude"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"message_delta","delta":{"stop_reason":"end_turn"}} - , - \\{"type":"message_stop"} - , - }; - - try runStreamedTurn(allocator, &conv, &rec, &events); - - var saw_complete = false; - for (rec.events.items) |ev| { - if (ev == .message_complete) { - saw_complete = true; - try testing.expect(ev.message_complete == null); - } - } - try testing.expect(saw_complete); -} - -test "captures thinking signature for round-trip" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "solve"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - \\{"type":"message_start","message":{"role":"assistant"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"step one"}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" step two"}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqQBabc"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"answer"}} - , - \\{"type":"content_block_stop","index":1} - , - \\{"type":"message_stop"} - , - }; - - try runStreamedTurn(allocator, &conv, &rec, &events); - - // The assistant message has Thinking + Text, with signature on the Thinking. - const asst = conv.messages.items[1]; - try testing.expectEqual(@as(usize, 2), asst.content.items.len); - try testing.expectEqualStrings("step one step two", asst.content.items[0].Thinking.text.items); - try testing.expectEqualStrings("EqQBabc", asst.content.items[0].Thinking.signature.?); - try testing.expectEqualStrings("answer", asst.content.items[1].Text.items); -} - -test "signature-only thinking block (display omitted)" { - // Anthropic emits a thinking block with only a signature_delta when - // `display: "omitted"` is configured. Verify we still capture the - // signature with empty thinking text. - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "hi"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - \\{"type":"message_start","message":{"role":"assistant"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":"","signature":""}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig123"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"hi back"}} - , - \\{"type":"content_block_stop","index":1} - , - \\{"type":"message_stop"} - , - }; - - 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("", asst.content.items[0].Thinking.text.items); - try testing.expectEqualStrings("sig123", asst.content.items[0].Thinking.signature.?); -} - -test "ping and unknown events are ignored" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "hi"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - \\{"type":"message_start","message":{"role":"assistant"}} - , - \\{"type":"ping"} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - , - \\{"type":"ping"} - , - \\{"type":"future_event_type","whatever":true} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"message_stop"} - , - }; - - try runStreamedTurn(allocator, &conv, &rec, &events); - - try testing.expectEqualStrings( - "ok", - conv.messages.items[1].content.items[0].Text.items, - ); -} - -test "tool_use blocks are captured with id, name, and assembled input" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "use a tool"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - \\{"type":"message_start","message":{"role":"assistant"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"tu_1","name":"calc","input":{}}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"x\":"}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"1}"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"done"}} - , - \\{"type":"content_block_stop","index":1} - , - \\{"type":"message_stop"} - , - }; - - try runStreamedTurn(allocator, &conv, &rec, &events); - - const asst = conv.messages.items[1]; - try testing.expectEqual(@as(usize, 2), asst.content.items.len); - - const tu = asst.content.items[0].ToolUse; - try testing.expectEqualStrings("tu_1", tu.id); - try testing.expectEqualStrings("calc", tu.name); - try testing.expectEqualStrings("{\"x\":1}", tu.input.items); - - try testing.expectEqualStrings("done", asst.content.items[1].Text.items); -} - -test "inbound wire tool name is decoded to dotted form" { - // Anthropic delivers the (wire-encoded) name whole at - // content_block_start; it is decoded to the internal dotted form for - // the conversation, session logs, and dispatch. - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "use a tool"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const events = [_][]const u8{ - \\{"type":"message_start","message":{"role":"assistant"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"t1","name":"calc__sum","input":{}}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"message_stop"} - , - }; - - try runStreamedTurn(allocator, &conv, &rec, &events); - - const tu = conv.messages.items[1].content.items[0].ToolUse; - try testing.expectEqualStrings("calc.sum", tu.name); -} - -test "error event propagates as Zig error" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "hi"); - - var queue = EventQueue.init(allocator); - defer queue.deinit(); - - var state: StreamState = .init(allocator); - defer state.deinit(); - - try handleEvent( - allocator, - \\{"type":"message_start","message":{"role":"assistant"}} - , - &state, - &queue, - ); - - const result = handleEvent( - allocator, - \\{"type":"error","error":{"type":"overloaded_error","message":"too busy"}} - , - &state, - &queue, - ); - // `overloaded_error` maps to the dedicated retryable error, and the - // provider's diagnostic is stashed for the agent's retry notice. - try testing.expectError(error.ProviderOverloaded, result); - try testing.expectEqualStrings("overloaded_error: too busy", state.stream_error_message.?); -} - -test "non-overloaded error event stays malformed and stashes message" { - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try addUserText(&conv, "hi"); - - var queue = EventQueue.init(allocator); - defer queue.deinit(); - - var state: StreamState = .init(allocator); - defer state.deinit(); - - const result = handleEvent( - allocator, - \\{"type":"error","error":{"type":"api_error","message":"boom"}} - , - &state, - &queue, - ); - try testing.expectError(error.ProviderStreamMalformed, result); - try testing.expectEqualStrings("api_error: boom", state.stream_error_message.?); -} - -test "two streamed turns persist assistant replies in the conversation" { - // Same regression scenario as the openai_chat test, adapted to Anthropic. - const allocator = testing.allocator; - - var conv = conversation.Conversation.init(allocator); - defer conv.deinit(); - try conv.addSystemMessage("Be brief."); - try addUserText(&conv, "hi"); - - var rec = EventRecorder.init(allocator); - defer rec.deinit(); - - const turn1 = [_][]const u8{ - \\{"type":"message_start","message":{"role":"assistant"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hi!"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"message_stop"} - , - }; - try runStreamedTurn(allocator, &conv, &rec, &turn1); - - try addUserText(&conv, "what did you say?"); - - const turn2 = [_][]const u8{ - \\{"type":"message_start","message":{"role":"assistant"}} - , - \\{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - , - \\{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I said: Hi!"}} - , - \\{"type":"content_block_stop","index":0} - , - \\{"type":"message_stop"} - , - }; - try runStreamedTurn(allocator, &conv, &rec, &turn2); - - // system + user + assistant + user + assistant = 5 - try testing.expectEqual(@as(usize, 5), conv.messages.items.len); - try testing.expectEqualStrings( - "I said: Hi!", - conv.messages.items[4].content.items[0].Text.items, - ); -} - -/// Helper: whether this config sends Bearer auth instead of `x-api-key`. -fn headerSliceUsesBearerAuth(cfg: *const config_mod.AnthropicMessagesConfig) bool { - return cfg.use_bearer_auth; -} - -/// Helper: build the header slice exactly as `open` does, given a config, -/// and return whether the interleaved beta header is present. -/// This lets us test the header-selection logic without a live HTTP connection. -fn headerSliceIncludesInterleaved(cfg: *const config_mod.AnthropicMessagesConfig) bool { - const send_interleaved = cfg.thinking == .enabled and cfg.thinking_interleaved; - return send_interleaved; -} - -test "oauth-backed anthropic auth uses bearer auth" { - const cfg: config_mod.AnthropicMessagesConfig = .{ - .api_key = "k", - .base_url = "https://api.individual.githubcopilot.com", - .model = "claude-sonnet-4-5", - .use_bearer_auth = true, - }; - try testing.expect(headerSliceUsesBearerAuth(&cfg)); -} - -test "plain anthropic auth uses x-api-key" { - const cfg: config_mod.AnthropicMessagesConfig = .{ - .api_key = "k", - .base_url = "https://api.anthropic.com", - .model = "claude-sonnet-4-5", - }; - try testing.expect(!headerSliceUsesBearerAuth(&cfg)); -} - -test "interleaved beta header: enabled when thinking=.enabled and interleaved=true" { - const cfg: config_mod.AnthropicMessagesConfig = .{ - .api_key = "k", - .base_url = "u", - .model = "m", - .thinking = .enabled, - .thinking_interleaved = true, - }; - try testing.expect(headerSliceIncludesInterleaved(&cfg)); -} - -test "interleaved beta header: absent when thinking=.enabled and interleaved=false" { - const cfg: config_mod.AnthropicMessagesConfig = .{ - .api_key = "k", - .base_url = "u", - .model = "m", - .thinking = .enabled, - .thinking_interleaved = false, - }; - try testing.expect(!headerSliceIncludesInterleaved(&cfg)); -} - -test "interleaved beta header: absent when thinking=.adaptive even if interleaved=true" { - const cfg: config_mod.AnthropicMessagesConfig = .{ - .api_key = "k", - .base_url = "u", - .model = "m", - .thinking = .adaptive, - .thinking_interleaved = true, - }; - // .adaptive does not send the header; interleaving is automatic there. - try testing.expect(!headerSliceIncludesInterleaved(&cfg)); -} - -test "interleaved beta header: absent when thinking=.disabled" { - const cfg: config_mod.AnthropicMessagesConfig = .{ - .api_key = "k", - .base_url = "u", - .model = "m", - .thinking = .disabled, - .thinking_interleaved = true, - }; - try testing.expect(!headerSliceIncludesInterleaved(&cfg)); -} |
