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