1// Package provider defines the LLM client abstraction used by zot.
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 "time"
12)
13
14// Role is the speaker of a Message.
15type Role string
16
17const (
18 RoleUser Role = "user"
19 RoleAssistant Role = "assistant"
20 RoleTool Role = "tool"
21)
22
23// Content is a block inside a Message. One of TextBlock, ImageBlock,
24// ToolCallBlock, or ToolResultBlock.
25type Content interface {
26 isContent()
27}
28
29// TextBlock is plain text content.
30type TextBlock struct {
31 Text string `json:"text"`
32 ThoughtSignature string `json:"thought_signature,omitempty"`
33}
34
35func (TextBlock) isContent() {}
36
37// ImageBlock is an inline image (PNG/JPEG/GIF/WebP).
38type ImageBlock struct {
39 MimeType string `json:"mime_type"`
40 Data []byte `json:"data"` // raw bytes; encoded to base64 on the wire
41 ThoughtSignature string `json:"thought_signature,omitempty"`
42}
43
44func (ImageBlock) isContent() {}
45
46// ToolCallBlock is an assistant-issued call to a tool.
47type ToolCallBlock struct {
48 ID string `json:"id"`
49 Name string `json:"name"`
50 Arguments json.RawMessage `json:"arguments"`
51 ThoughtSignature string `json:"thought_signature,omitempty"`
52}
53
54func (ToolCallBlock) isContent() {}
55
56// ToolResultBlock is the result of a tool execution, attached to a
57// Message with Role == RoleTool.
58type ToolResultBlock struct {
59 CallID string `json:"call_id"`
60 Content []Content `json:"content"`
61 IsError bool `json:"is_error"`
62}
63
64func (ToolResultBlock) isContent() {}
65
66// ReasoningBlock carries the assistant's chain-of-thought metadata so
67// providers that require it on follow-up requests (OpenAI Codex with
68// thinking enabled) can replay the same payload they emitted earlier.
69// Summary is the human-readable reasoning summary (may be empty); the
70// encrypted blob is opaque to zot. ID is the provider-issued reasoning
71// item id.
72type ReasoningBlock struct {
73 ID string `json:"reasoning_id,omitempty"`
74 Summary string `json:"summary,omitempty"`
75 Encrypted string `json:"encrypted_content,omitempty"`
76}
77
78func (ReasoningBlock) isContent() {}
79
80// RepairOrphanedToolResults removes tool_result content blocks (and
81// entire messages that become empty) when the matching tool_use ID
82// does not appear anywhere in the given messages. Resume tails,
83// compaction repair, and provider request builders all need this so
84// the upstream API never sees a tool_call_id with no corresponding
85// assistant tool_call earlier in the same request.
86func RepairOrphanedToolResults(msgs []Message) []Message {
87 useIDs := map[string]bool{}
88 for _, m := range msgs {
89 for _, c := range m.Content {
90 if tc, ok := c.(ToolCallBlock); ok {
91 useIDs[tc.ID] = true
92 }
93 }
94 }
95 out := make([]Message, 0, len(msgs))
96 for _, m := range msgs {
97 var filtered []Content
98 for _, c := range m.Content {
99 if tr, ok := c.(ToolResultBlock); ok {
100 if !useIDs[tr.CallID] {
101 continue
102 }
103 }
104 filtered = append(filtered, c)
105 }
106 if len(filtered) > 0 {
107 copy := m
108 copy.Content = filtered
109 out = append(out, copy)
110 }
111 }
112 return out
113}
114
115// Message is a single turn in the conversation.
116type Message struct {
117 Role Role `json:"role"`
118 Content []Content `json:"content"`
119 Time time.Time `json:"time"`
120 Meta map[string]string `json:"meta,omitempty"`
121 AddedToolNames []string `json:"added_tool_names,omitempty"`
122}
123
124// Tool is a tool definition advertised to the LLM.
125type Tool struct {
126 Name string `json:"name"`
127 Description string `json:"description"`
128 Schema json.RawMessage `json:"schema"` // JSON Schema for arguments
129 // Deferred hides the definition until a tool result activates it.
130 Deferred bool `json:"deferred,omitempty"`
131}
132
133// activeToolDefinitions returns eager tools plus deferred tools activated by
134// prior tool results. Providers with a native position-sensitive deferred-tool
135// format can use the activation markers directly instead.
136func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
137 activated := activatedToolNames(messages)
138 active := make([]Tool, 0, len(tools))
139 for _, tool := range tools {
140 if !tool.Deferred || activated[tool.Name] {
141 active = append(active, tool)
142 }
143 }
144 return active
145}
146
147func activatedToolNames(messages []Message) map[string]bool {
148 activated := make(map[string]bool)
149 for _, message := range messages {
150 for _, name := range message.AddedToolNames {
151 activated[name] = true
152 }
153 }
154 return activated
155}
156
157// Usage aggregates token counts and cost for a turn.
158type Usage struct {
159 InputTokens int `json:"input_tokens"`
160 OutputTokens int `json:"output_tokens"`
161 CacheReadTokens int `json:"cache_read_tokens"`
162 CacheWriteTokens int `json:"cache_write_tokens"`
163 CostUSD float64 `json:"cost_usd"`
164}
165
166// Add returns u plus v.
167func (u Usage) Add(v Usage) Usage {
168 return Usage{
169 InputTokens: u.InputTokens + v.InputTokens,
170 OutputTokens: u.OutputTokens + v.OutputTokens,
171 CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
172 CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
173 CostUSD: u.CostUSD + v.CostUSD,
174 }
175}
176
177// StopReason describes why a turn ended.
178type StopReason string
179
180const (
181 StopEnd StopReason = "end"
182 StopLength StopReason = "length"
183 StopToolUse StopReason = "tool_use"
184 StopError StopReason = "error"
185 StopAborted StopReason = "aborted"
186)
187
188// Event is one item from a provider stream.
189type Event interface {
190 isEvent()
191}
192
193type EventStart struct {
194 Model string
195 Provider string
196}
197
198func (EventStart) isEvent() {}
199
200type EventTextDelta struct {
201 Delta string
202}
203
204func (EventTextDelta) isEvent() {}
205
206type EventToolStart struct {
207 ID string
208 Name string
209}
210
211func (EventToolStart) isEvent() {}
212
213type EventToolArgs struct {
214 ID string
215 Delta string // partial JSON
216}
217
218func (EventToolArgs) isEvent() {}
219
220type EventToolEnd struct {
221 ID string
222}
223
224func (EventToolEnd) isEvent() {}
225
226type EventUsage struct {
227 Usage Usage
228}
229
230func (EventUsage) isEvent() {}
231
232// EventDone is always the final event on a stream.
233type EventDone struct {
234 Stop StopReason
235 Err error
236 Message Message // fully assembled assistant message
237}
238
239func (EventDone) isEvent() {}
240
241// Request is a single LLM call.
242type Request struct {
243 Model string
244 System string
245 Messages []Message
246 Tools []Tool
247 MaxTokens int
248 Temperature *float32
249 // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
250 // "max". Empty disables reasoning. The max tier is sent natively only to
251 // models that support it and clamped elsewhere.
252 Reasoning string
253}
254
255// Client is an LLM streaming client.
256type Client interface {
257 // Name returns "anthropic" or "openai".
258 Name() string
259 // Stream starts a request. The returned channel delivers events
260 // and is closed after EventDone. Errors during request setup are
261 // returned directly; runtime errors arrive as EventDone{Err: ...}.
262 Stream(ctx context.Context, req Request) (<-chan Event, error)
263}