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