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