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// ToolTiming records the wall-clock bounds and monotonic duration of a tool
62// invocation. It is optional so sessions written before timing was added
63// remain valid and compact.
64type ToolTiming struct {
65 StartedAt time.Time `json:"started_at,omitempty"`
66 CompletedAt time.Time `json:"completed_at,omitempty"`
67 Duration time.Duration `json:"duration,omitempty"`
68}
69
70// ToolResultBlock is the result of a tool execution, attached to a
71// Message with Role == RoleTool.
72type ToolResultBlock struct {
73 CallID string `json:"call_id"`
74 Content []Content `json:"content"`
75 IsError bool `json:"is_error"`
76 Timing *ToolTiming `json:"timing,omitempty"`
77}
78
79func (ToolResultBlock) isContent() {}
80
81// ReasoningBlock carries the assistant's chain-of-thought metadata so
82// providers that require it on follow-up requests (OpenAI Codex with
83// thinking enabled) can replay the same payload they emitted earlier.
84// Summary is the human-readable reasoning summary (may be empty); the
85// encrypted blob is opaque to zut. ID is the provider-issued reasoning
86// item id.
87type ReasoningBlock struct {
88 ID string `json:"reasoning_id,omitempty"`
89 Summary string `json:"summary,omitempty"`
90 Encrypted string `json:"encrypted_content,omitempty"`
91}
92
93func (ReasoningBlock) isContent() {}
94
95// RepairOrphanedToolResults removes tool_result content blocks (and
96// entire messages that become empty) when the matching tool_use ID
97// does not appear anywhere in the given messages. Resume tails,
98// compaction repair, and provider request builders all need this so
99// the upstream API never sees a tool_call_id with no corresponding
100// assistant tool_call earlier in the same request.
101func RepairOrphanedToolResults(msgs []Message) []Message {
102 useIDs := map[string]bool{}
103 for _, m := range msgs {
104 for _, c := range m.Content {
105 if tc, ok := c.(ToolCallBlock); ok {
106 useIDs[tc.ID] = true
107 }
108 }
109 }
110 out := make([]Message, 0, len(msgs))
111 for _, m := range msgs {
112 var filtered []Content
113 for _, c := range m.Content {
114 if tr, ok := c.(ToolResultBlock); ok {
115 if !useIDs[tr.CallID] {
116 continue
117 }
118 }
119 filtered = append(filtered, c)
120 }
121 if len(filtered) > 0 {
122 copy := m
123 copy.Content = filtered
124 out = append(out, copy)
125 }
126 }
127 return out
128}
129
130// Message is a single turn in the conversation.
131type Message struct {
132 Role Role `json:"role"`
133 Content []Content `json:"content"`
134 Time time.Time `json:"time"`
135 Meta map[string]string `json:"meta,omitempty"`
136 AddedToolNames []string `json:"added_tool_names,omitempty"`
137}
138
139// Tool is a tool definition advertised to the LLM.
140type Tool struct {
141 Name string `json:"name"`
142 Description string `json:"description"`
143 Schema json.RawMessage `json:"schema"` // JSON Schema for arguments
144 // Deferred hides the definition until a tool result activates it.
145 Deferred bool `json:"deferred,omitempty"`
146}
147
148// activeToolDefinitions returns eager tools plus deferred tools activated by
149// prior tool results. Providers with a native position-sensitive deferred-tool
150// format can use the activation markers directly instead.
151func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
152 activated := activatedToolNames(messages)
153 active := make([]Tool, 0, len(tools))
154 for _, tool := range tools {
155 if !tool.Deferred || activated[tool.Name] {
156 active = append(active, tool)
157 }
158 }
159 return active
160}
161
162func activatedToolNames(messages []Message) map[string]bool {
163 activated := make(map[string]bool)
164 for _, message := range messages {
165 for _, name := range message.AddedToolNames {
166 activated[name] = true
167 }
168 }
169 return activated
170}
171
172// Usage aggregates token counts and cost for a turn.
173type Usage struct {
174 InputTokens int `json:"input_tokens"`
175 OutputTokens int `json:"output_tokens"`
176 ReasoningTokens int `json:"reasoning_tokens"`
177 ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
178 CacheReadTokens int `json:"cache_read_tokens"`
179 CacheWriteTokens int `json:"cache_write_tokens"`
180 CostUSD float64 `json:"cost_usd"`
181}
182
183// Add returns u plus v. A reasoning total is known only when both
184// component usage reports include a separate reasoning-token count.
185func (u Usage) Add(v Usage) Usage {
186 return Usage{
187 InputTokens: u.InputTokens + v.InputTokens,
188 OutputTokens: u.OutputTokens + v.OutputTokens,
189 ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
190 ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
191 CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
192 CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
193 CostUSD: u.CostUSD + v.CostUSD,
194 }
195}
196
197// StopReason describes why a turn ended.
198type StopReason string
199
200const (
201 StopEnd StopReason = "end"
202 StopLength StopReason = "length"
203 StopToolUse StopReason = "tool_use"
204 StopError StopReason = "error"
205 StopAborted StopReason = "aborted"
206)
207
208// Event is one item from a provider stream.
209type Event interface {
210 isEvent()
211}
212
213type EventStart struct {
214 Model string
215 Provider string
216}
217
218func (EventStart) isEvent() {}
219
220type EventTextDelta struct {
221 Delta string
222}
223
224func (EventTextDelta) isEvent() {}
225
226type EventToolStart struct {
227 ID string
228 Name string
229}
230
231func (EventToolStart) isEvent() {}
232
233type EventToolArgs struct {
234 ID string
235 Delta string // partial JSON
236}
237
238func (EventToolArgs) isEvent() {}
239
240type EventToolEnd struct {
241 ID string
242}
243
244func (EventToolEnd) isEvent() {}
245
246type EventUsage struct {
247 Usage Usage
248}
249
250func (EventUsage) isEvent() {}
251
252// EventDone is always the final event on a stream.
253type EventDone struct {
254 Stop StopReason
255 Err error
256 Message Message // fully assembled assistant message
257}
258
259func (EventDone) isEvent() {}
260
261// RequestLifecycle observes request attempts and retry delays without being
262// serialized onto a provider wire. Implementations must call RequestAttempt
263// immediately before an outbound attempt and RetryScheduled before sleeping.
264type RequestLifecycle interface {
265 RequestAttempt(attempt, maxAttempts int)
266 RetryScheduled(attempt, maxAttempts int, delay time.Duration)
267}
268
269// RequestAttemptIDLifecycle optionally receives a provider-generated identity
270// for each network attempt. It is deliberately separate from RequestContext:
271// SessionID and TurnID remain stable core identities, while request IDs are
272// transport-local and must never be serialized into provider payloads.
273type RequestAttemptIDLifecycle interface {
274 RequestAttemptID(requestID string, attempt, maxAttempts int)
275}
276
277// RequestFailureReason is an allowlisted request-failure category. It is safe
278// to persist; raw provider errors and response bodies are not.
279type RequestFailureReason string
280
281const (
282 RequestFailureOverload RequestFailureReason = "overload"
283 RequestFailureRateLimit RequestFailureReason = "rate_limit"
284 RequestFailureQuota RequestFailureReason = "quota"
285 RequestFailureServer RequestFailureReason = "server"
286 RequestFailureNetwork RequestFailureReason = "network"
287 RequestFailureTimeout RequestFailureReason = "timeout"
288 RequestFailureClient RequestFailureReason = "client"
289 RequestFailureUnknown RequestFailureReason = "unknown"
290)
291
292// RequestFailureLifecycle optionally augments RequestLifecycle with sanitized
293// failed-attempt metadata. Keeping it separate preserves existing lifecycle
294// implementations.
295type RequestFailureLifecycle interface {
296 RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool)
297}
298
299// RequestContext identifies one logical agent session and one accepted turn.
300// Values are opaque provider-neutral correlation IDs. Providers may translate
301// them into cache controls or session-affinity fields, but must not replace
302// them with transport-attempt IDs.
303type RequestContext struct {
304 SessionID string
305 TurnID string
306}
307
308// Request is a single LLM call.
309type Request struct {
310 Model string
311 System string
312 SystemContext string
313 Messages []Message
314 Tools []Tool
315 MaxTokens int
316 Temperature *float32
317 // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
318 // "max". Empty disables reasoning. The max tier is sent natively only to
319 // models that support it and clamped elsewhere.
320 Reasoning string
321 // FastMode requests OpenAI's fast service tier. Providers that do not
322 // implement the OpenAI service-tier contract reject the request.
323 FastMode bool
324 // Lifecycle receives in-memory request-attempt notifications. It is not
325 // included in any provider request payload.
326 Lifecycle RequestLifecycle
327 // Context carries the stable logical session and turn identities for this
328 // request. Provider clients own any per-network-attempt identity.
329 Context RequestContext
330}
331
332// SystemPrompt returns the stable system prompt plus optional model-only
333// session context without requiring callers to mutate their system prompt.
334func (r Request) SystemPrompt() string {
335 if r.SystemContext == "" {
336 return r.System
337 }
338 if r.System == "" {
339 return r.SystemContext
340 }
341 return r.System + "\n\n" + r.SystemContext
342}
343
344// OpenAI's current Fast mode is represented as the priority service tier on
345// the wire, including the ChatGPT/Codex Responses endpoint.
346const fastModeServiceTier = "priority"
347
348// SupportsFastMode reports whether a provider uses an OpenAI request
349// contract that supports the fast service tier.
350func SupportsFastMode(providerName string) bool {
351 switch strings.ToLower(strings.TrimSpace(providerName)) {
352 case "openai", "openai-codex", "openai-responses":
353 return true
354 default:
355 return false
356 }
357}
358
359// ValidateFastMode rejects fast-mode requests before a provider performs
360// any network or credential work. Keep this check at the provider boundary
361// so direct Client users get the same contract as the core agent loop.
362func ValidateFastMode(providerName string, enabled bool) error {
363 if !enabled || SupportsFastMode(providerName) {
364 return nil
365 }
366 return fmt.Errorf("fast mode is only supported for OpenAI providers, not %q", providerName)
367}
368
369// Client is an LLM streaming client.
370type Client interface {
371 // Name returns "anthropic" or "openai".
372 Name() string
373 // Stream starts a request. The returned channel delivers events
374 // and is closed after EventDone. Errors during request setup are
375 // returned directly; runtime errors arrive as EventDone{Err: ...}.
376 Stream(ctx context.Context, req Request) (<-chan Event, error)
377}
378
379// ClientCloser is implemented by clients that retain resources beyond one
380// Stream call. Hosts with an explicit runtime lifetime should close it when
381// that runtime stops. Most HTTP/SSE clients need no cleanup.
382type ClientCloser interface {
383 Close() error
384}