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