1// Package provider defines the LLM client abstraction used by zut.
2//
3// It supports exactly two providers: Anthropic (Messages API) and
4// OpenAI (Chat Completions API). Everything above this package operates
5// on the types declared here and does not know about HTTP or SSE.
6package provider
7
8import (
9 "context"
10 "encoding/json"
11 "fmt"
12 "strings"
13 "time"
14)
15
16// Role is the speaker of a Message.
17type Role string
18
19const (
20 RoleUser Role = "user"
21 RoleAssistant Role = "assistant"
22 RoleTool Role = "tool"
23)
24
25// Content is a block inside a Message. One of TextBlock, ImageBlock,
26// ToolCallBlock, or ToolResultBlock.
27type Content interface {
28 isContent()
29}
30
31// TextBlock is plain text content.
32type TextBlock struct {
33 Text string `json:"text"`
34 ThoughtSignature string `json:"thought_signature,omitempty"`
35}
36
37func (TextBlock) isContent() {}
38
39// ImageBlock is an inline image (PNG/JPEG/GIF/WebP).
40type ImageBlock struct {
41 MimeType string `json:"mime_type"`
42 Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire
43 ThoughtSignature string `json:"thought_signature,omitempty"`
44}
45
46func (ImageBlock) isContent() {}
47
48// ToolCallBlock is an assistant-issued call to a tool.
49type ToolCallBlock struct {
50 ID string `json:"id"`
51 Name string `json:"name"`
52 Arguments json.RawMessage `json:"arguments"`
53 ThoughtSignature string `json:"thought_signature,omitempty"`
54 // Origin is host-only routing metadata. It is never serialized to a
55 // provider or stored in the transcript.
56 Origin string `json:"-"`
57}
58
59func (ToolCallBlock) isContent() {}
60
61// ToolResultBlock is the result of a tool execution, attached to a
62// Message with Role == RoleTool.
63type ToolResultBlock struct {
64 CallID string `json:"call_id"`
65 Content []Content `json:"content"`
66 IsError bool `json:"is_error"`
67}
68
69func (ToolResultBlock) isContent() {}
70
71// ReasoningBlock carries the assistant's chain-of-thought metadata so
72// providers that require it on follow-up requests (OpenAI Codex with
73// thinking enabled) can replay the same payload they emitted earlier.
74// Summary is the human-readable reasoning summary (may be empty); the
75// encrypted blob is opaque to zut. ID is the provider-issued reasoning
76// item id.
77type ReasoningBlock struct {
78 ID string `json:"reasoning_id,omitempty"`
79 Summary string `json:"summary,omitempty"`
80 Encrypted string `json:"encrypted_content,omitempty"`
81}
82
83func (ReasoningBlock) isContent() {}
84
85// RepairOrphanedToolResults removes tool_result content blocks (and
86// entire messages that become empty) when the matching tool_use ID
87// does not appear anywhere in the given messages. Resume tails,
88// compaction repair, and provider request builders all need this so
89// the upstream API never sees a tool_call_id with no corresponding
90// assistant tool_call earlier in the same request.
91func RepairOrphanedToolResults(msgs []Message) []Message {
92 useIDs := map[string]bool{}
93 for _, m := range msgs {
94 for _, c := range m.Content {
95 if tc, ok := c.(ToolCallBlock); ok {
96 useIDs[tc.ID] = true
97 }
98 }
99 }
100 out := make([]Message, 0, len(msgs))
101 for _, m := range msgs {
102 var filtered []Content
103 for _, c := range m.Content {
104 if tr, ok := c.(ToolResultBlock); ok {
105 if !useIDs[tr.CallID] {
106 continue
107 }
108 }
109 filtered = append(filtered, c)
110 }
111 if len(filtered) > 0 {
112 copy := m
113 copy.Content = filtered
114 out = append(out, copy)
115 }
116 }
117 return out
118}
119
120// Message is a single turn in the conversation.
121type Message struct {
122 Role Role `json:"role"`
123 Content []Content `json:"content"`
124 Time time.Time `json:"time"`
125 Meta map[string]string `json:"meta,omitempty"`
126 AddedToolNames []string `json:"added_tool_names,omitempty"`
127}
128
129// Tool is a tool definition advertised to the LLM.
130type Tool struct {
131 Name string `json:"name"`
132 Description string `json:"description"`
133 Schema json.RawMessage `json:"schema"` // JSON Schema for arguments
134 // Deferred hides the definition until a tool result activates it.
135 Deferred bool `json:"deferred,omitempty"`
136}
137
138// activeToolDefinitions returns eager tools plus deferred tools activated by
139// prior tool results. Providers with a native position-sensitive deferred-tool
140// format can use the activation markers directly instead.
141func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
142 activated := activatedToolNames(messages)
143 active := make([]Tool, 0, len(tools))
144 for _, tool := range tools {
145 if !tool.Deferred || activated[tool.Name] {
146 active = append(active, tool)
147 }
148 }
149 return active
150}
151
152func activatedToolNames(messages []Message) map[string]bool {
153 activated := make(map[string]bool)
154 for _, message := range messages {
155 for _, name := range message.AddedToolNames {
156 activated[name] = true
157 }
158 }
159 return activated
160}
161
162// Usage aggregates token counts and cost for a turn.
163type Usage struct {
164 InputTokens int `json:"input_tokens"`
165 OutputTokens int `json:"output_tokens"`
166 ReasoningTokens int `json:"reasoning_tokens"`
167 ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
168 CacheReadTokens int `json:"cache_read_tokens"`
169 CacheWriteTokens int `json:"cache_write_tokens"`
170 CostUSD float64 `json:"cost_usd"`
171}
172
173// Add returns u plus v. A reasoning total is known only when both
174// component usage reports include a separate reasoning-token count.
175func (u Usage) Add(v Usage) Usage {
176 return Usage{
177 InputTokens: u.InputTokens + v.InputTokens,
178 OutputTokens: u.OutputTokens + v.OutputTokens,
179 ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
180 ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
181 CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
182 CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
183 CostUSD: u.CostUSD + v.CostUSD,
184 }
185}
186
187// StopReason describes why a turn ended.
188type StopReason string
189
190const (
191 StopEnd StopReason = "end"
192 StopLength StopReason = "length"
193 StopToolUse StopReason = "tool_use"
194 StopError StopReason = "error"
195 StopAborted StopReason = "aborted"
196)
197
198// Event is one item from a provider stream.
199type Event interface {
200 isEvent()
201}
202
203type EventStart struct {
204 Model string
205 Provider string
206}
207
208func (EventStart) isEvent() {}
209
210type EventTextDelta struct {
211 Delta string
212}
213
214func (EventTextDelta) isEvent() {}
215
216type EventToolStart struct {
217 ID string
218 Name string
219}
220
221func (EventToolStart) isEvent() {}
222
223type EventToolArgs struct {
224 ID string
225 Delta string // partial JSON
226}
227
228func (EventToolArgs) isEvent() {}
229
230type EventToolEnd struct {
231 ID string
232}
233
234func (EventToolEnd) isEvent() {}
235
236type EventUsage struct {
237 Usage Usage
238}
239
240func (EventUsage) isEvent() {}
241
242// EventDone is always the final event on a stream.
243type EventDone struct {
244 Stop StopReason
245 Err error
246 Message Message // fully assembled assistant message
247}
248
249func (EventDone) isEvent() {}
250
251// RequestLifecycle observes request attempts and retry delays without being
252// serialized onto a provider wire. Implementations must call RequestAttempt
253// immediately before an outbound attempt and RetryScheduled before sleeping.
254type RequestLifecycle interface {
255 RequestAttempt(attempt, maxAttempts int)
256 RetryScheduled(attempt, maxAttempts int, delay time.Duration)
257}
258
259// Request is a single LLM call.
260type Request struct {
261 Model string
262 System string
263 Messages []Message
264 Tools []Tool
265 MaxTokens int
266 Temperature *float32
267 // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
268 // "max". Empty disables reasoning. The max tier is sent natively only to
269 // models that support it and clamped elsewhere.
270 Reasoning string
271 // FastMode requests OpenAI's fast service tier. Providers that do not
272 // implement the OpenAI service-tier contract reject the request.
273 FastMode bool
274 // Lifecycle receives in-memory request-attempt notifications. It is not
275 // included in any provider request payload.
276 Lifecycle RequestLifecycle
277}
278
279// OpenAI's current Fast mode is represented as the priority service tier on
280// the wire, including the ChatGPT/Codex Responses endpoint.
281const fastModeServiceTier = "priority"
282
283// SupportsFastMode reports whether a provider uses an OpenAI request
284// contract that supports the fast service tier.
285func SupportsFastMode(providerName string) bool {
286 switch strings.ToLower(strings.TrimSpace(providerName)) {
287 case "openai", "openai-codex", "openai-responses":
288 return true
289 default:
290 return false
291 }
292}
293
294// ValidateFastMode rejects fast-mode requests before a provider performs
295// any network or credential work. Keep this check at the provider boundary
296// so direct Client users get the same contract as the core agent loop.
297func ValidateFastMode(providerName string, enabled bool) error {
298 if !enabled || SupportsFastMode(providerName) {
299 return nil
300 }
301 return fmt.Errorf("fast mode is only supported for OpenAI providers, not %q", providerName)
302}
303
304// Client is an LLM streaming client.
305type Client interface {
306 // Name returns "anthropic" or "openai".
307 Name() string
308 // Stream starts a request. The returned channel delivers events
309 // and is closed after EventDone. Errors during request setup are
310 // returned directly; runtime errors arrive as EventDone{Err: ...}.
311 Stream(ctx context.Context, req Request) (<-chan Event, error)
312}