1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
|
# Phase 2: Anthropic Provider
**Status: complete.** Anthropic Messages API streams end-to-end alongside the phase-1 OpenAI provider; the Receiver callback sequence is identical across both; system prompts extract to the top-level field; thinking blocks round-trip via captured `signature` deltas. Refinements that diverged from the original plan: file/type names use the explicit dialect (`provider_anthropic_messages`, `AnthropicMessagesConfig`) to leave room for `openai_responses` and future Anthropic shapes; `Config` is a tagged union keyed by `APIStyle` rather than separate types at call sites; concrete provider types are internal — callers construct via `provider.Provider.init(allocator, io, config)`. Wire-level gotchas worth remembering: both providers send `accept-encoding: identity` because gzip buffers small SSE frames and defeats streaming; the read loop uses `readVec` rather than `readSliceShort` because the latter fills its buffer before returning, which also defeats streaming. Tool use / tool result blocks remain stubbed for phase 3+.
## Goal
Add Anthropic as a second provider, validating that the Provider abstraction and internal conversation model are genuinely provider-agnostic — not just OpenAI in disguise.
## Deliverable
A working `provider_anthropic.zig` that can hold a streaming conversation via Anthropic's API. At the end of this phase, you can:
- Switch between OpenAI and Anthropic providers with no changes to the agent loop or conversation model.
- Stream responses from either provider and see the same callback sequence (onMessageStart, onBlockStart, onContentDelta, onBlockComplete, onMessageComplete).
- Observe thinking and text content streaming correctly from Anthropic models.
## What is usable at the end
| Capability | How to exercise it |
|---|---|
| Create an Anthropic provider | `AnthropicProvider.init(allocator, config)` |
| Run a chat via Anthropic | Same `agent.runStep()` call, different provider |
| Stream Anthropic responses | Same Receiver interface, same callback sequence |
| System prompts with Anthropic | System messages extracted and sent as top-level system field |
## What is explicitly out of scope
- Tools and tool-use (phase 3+)
- Extensions (phase 3+)
- Conversation serialization / disk persistence (phase 4)
- Server/proxy mode (future)
- Google API provider (future)
---
## Receiver Interface
The Receiver interface with the full 5-callback lifecycle is defined in phase 1. Both providers must produce the same callback sequence:
- onMessageStart → onBlockStart → onContentDelta(s) → onBlockComplete → ... → onMessageComplete
See `phase-1.md` for the full definition and contract.
### How OpenAI synthesizes the callbacks
Defined in phase 1. OpenAI has no explicit block boundaries; the provider infers them via a state machine that tracks the active block type and emits start/complete callbacks on transitions.
### How Anthropic maps to the callbacks
Anthropic's structured events map directly:
| Anthropic event | Callback |
|---|---|
| `message_start` | onMessageStart(.assistant) |
| `content_block_start` | onBlockStart(type, index, meta if ToolUse) |
| `content_block_delta` | onContentDelta(index, delta bytes) |
| `content_block_stop` | onBlockComplete(index, assembled block) |
| `message_delta` + `message_stop` | onMessageComplete(assembled message) |
No inference needed — Anthropic gives us explicit boundaries.
---
## Anthropic Request Serialization
### Wire format differences from OpenAI
| Aspect | OpenAI | Anthropic |
|---|---|---|
| System prompt | Messages with `role: "system"` | Top-level `system` field (string) |
| Content shape | String or array of parts | Always array of content blocks |
| Tool results | Separate `role: "tool"` messages | Content blocks on `role: "user"` messages |
| Auth | `Authorization: Bearer <key>` | `x-api-key: <key>` + `anthropic-version` header |
| Streaming | `stream: true` in request body | `stream: true` in request body |
### Serialization rules
**System messages**: Extract all `role=.system` messages from the conversation. Concatenate their Text block contents into a single string. Set as the top-level `system` field. Do not include them in the messages array.
**User messages**: Emit as `role: "user"`. Content blocks become Anthropic content block format:
- Text → `{ "type": "text", "text": "..." }`
- ToolResult → `{ "type": "tool_result", "tool_use_id": "...", "content": "..." }` (phase 3+)
**Assistant messages**: Emit as `role: "assistant"`. Content blocks:
- Text → `{ "type": "text", "text": "..." }`
- Thinking → `{ "type": "thinking", "thinking": "..." }`
- ToolUse → `{ "type": "tool_use", "id": "...", "name": "...", "input": {...} }` (phase 3+)
Note: Anthropic expects ToolUse's `input` as a parsed JSON object, not a string. Since we store `input` as raw bytes in a TextualBlock, we will need to parse it into a `std.json.Value` during Anthropic serialization. This is the one place where libpanto does parse tool input JSON — it's a serialization requirement, not an interpretation of the tool schema. The round-trip guarantee is: the bytes we stored serialize back to equivalent JSON when sent to Anthropic.
---
## Anthropic Streaming Event Parser
Each SSE event is a complete JSON object. The event type is in a top-level `type` field.
### Event types and handling
| Event type | What we extract | Action |
|---|---|---|
| `message_start` | `message.role`, `message.id`, `message.model` | Emit onMessageStart; begin assembling Message |
| `content_block_start` | `content_block.type`, `content_block.index`, `content_block.id`, `content_block.name` | Emit onBlockStart; create new TextualBlock or ToolUseBlock |
| `content_block_delta` | `delta.type` (text_delta, thinking_delta, input_json_delta), `delta.text` or `delta.thinking` or `delta.partial_json` | Append to current block's buffer; emit onContentDelta |
| `content_block_stop` | `index` | Emit onBlockComplete with assembled block |
| `message_delta` | `delta.stop_reason`, `usage` | Track stop reason for onMessageComplete |
| `message_stop` | (none) | Emit onMessageComplete with fully assembled Message |
The parser is a separate concern from the SSE line parser (`sse.zig`). The SSE parser reassembles byte chunks into complete `data: {...}` events. The Anthropic event parser interprets the JSON of each event. They compose: `HTTP read → SSE parser → event strings → Anthropic event parser → callbacks`.
---
## Module Changes
### New files
```
src/provider_anthropic.zig // Anthropic provider implementation
```
### Modified files
- `provider.zig` — ReceiverVTable expanded to the 5-callback lifecycle
- `provider_openai.zig` — No changes needed (block synthesis already built in phase 1)
- `agent.zig` — Any changes needed for expanded Receiver (should be minimal since Receiver is an interface)
### Config
```
AnthropicConfig = struct {
api_key: []const u8,
base_url: []const u8, // e.g. "https://api.anthropic.com"
model: []const u8, // e.g. "claude-sonnet-4-20250514"
api_version: []const u8, // e.g. "2023-06-01"
};
```
Separate from `OpenAIConfig`. The CLI and any higher-level config can unify them; libpanto treats them as distinct.
---
## Testing Strategy
### Unit tests
| What | How |
|---|---|
| Anthropic serialization | Create conversations with system/user/assistant messages, serialize to Anthropic JSON, verify structure and system prompt extraction |
| Streaming event parser | Feed canned Anthropic SSE events (message_start, content_block_start, etc.) and verify correct callback sequence and assembled output |
| Block boundary synthesis | Feed OpenAI-style deltas (reasoning → content transitions) and verify onBlockStart/onBlockComplete emitted correctly |
| Receiver contract | Verify that both providers produce the same callback sequence for equivalent conversations |
### Integration test (manual)
- Run `panto` binary against Anthropic API with a real API key
- Hold a multi-turn conversation with thinking model
- Verify thinking and text stream correctly
- Switch to OpenAI provider, verify same experience
|