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 // Server is true when the provider executes this tool itself
53 // (OpenRouter server tools). The agent must not run these locally
54 // or send a client tool_result for them.
55 Server bool `json:"server,omitempty"`
56}
57
58func (ToolCallBlock) isContent() {}
59
60// ToolResultBlock is the result of a tool execution, attached to a
61// Message with Role == RoleTool.
62type ToolResultBlock struct {
63 CallID string `json:"call_id"`
64 Content []Content `json:"content"`
65 IsError bool `json:"is_error"`
66}
67
68func (ToolResultBlock) isContent() {}
69
70// ReasoningBlock carries the assistant's chain-of-thought metadata so
71// providers that require it on follow-up requests (OpenAI Codex with
72// thinking enabled) can replay the same payload they emitted earlier.
73// Summary is the human-readable reasoning summary (may be empty); the
74// encrypted blob is opaque to zot. ID is the provider-issued reasoning
75// item id.
76type ReasoningBlock struct {
77 ID string `json:"reasoning_id,omitempty"`
78 Summary string `json:"summary,omitempty"`
79 Encrypted string `json:"encrypted_content,omitempty"`
80}
81
82func (ReasoningBlock) isContent() {}
83
84// RepairOrphanedToolResults removes invalid tool_result content blocks
85// and entire messages that become empty. A result is invalid when there
86// is no unmatched tool_use with the same ID anywhere in the messages.
87// Tool IDs normally are unique, but a provider can reuse one across turns.
88// Counting occurrences preserves each valid pair while still dropping
89// duplicate results. Resume tails, compaction repair, and provider request
90// builders all need this so the upstream API sees one result for each
91// referenced tool call.
92func RepairOrphanedToolResults(msgs []Message) []Message {
93 useCounts := map[string]int{}
94 for _, m := range msgs {
95 for _, c := range m.Content {
96 if tc, ok := c.(ToolCallBlock); ok {
97 useCounts[tc.ID]++
98 }
99 }
100 }
101 resultCounts := map[string]int{}
102 out := make([]Message, 0, len(msgs))
103 for _, m := range msgs {
104 var filtered []Content
105 for _, c := range m.Content {
106 if tr, ok := c.(ToolResultBlock); ok {
107 if resultCounts[tr.CallID] >= useCounts[tr.CallID] {
108 continue
109 }
110 resultCounts[tr.CallID]++
111 }
112 filtered = append(filtered, c)
113 }
114 if len(filtered) > 0 {
115 copy := m
116 copy.Content = filtered
117 out = append(out, copy)
118 }
119 }
120 return out
121}
122
123// Message is a single turn in the conversation.
124type Message struct {
125 Role Role `json:"role"`
126 Content []Content `json:"content"`
127 Time time.Time `json:"time"`
128 Meta map[string]string `json:"meta,omitempty"`
129 AddedToolNames []string `json:"added_tool_names,omitempty"`
130}
131
132// Tool is a tool definition advertised to the LLM.
133type Tool struct {
134 Name string `json:"name"`
135 Description string `json:"description"`
136 Schema json.RawMessage `json:"schema"` // JSON Schema for arguments
137 // Deferred hides the definition until a tool result activates it.
138 Deferred bool `json:"deferred,omitempty"`
139}
140
141// activeToolDefinitions returns eager tools plus deferred tools activated by
142// prior tool results. Providers with a native position-sensitive deferred-tool
143// format can use the activation markers directly instead.
144func activeToolDefinitions(tools []Tool, messages []Message) []Tool {
145 activated := activatedToolNames(messages)
146 active := make([]Tool, 0, len(tools))
147 for _, tool := range tools {
148 if !tool.Deferred || activated[tool.Name] {
149 active = append(active, tool)
150 }
151 }
152 return active
153}
154
155func activatedToolNames(messages []Message) map[string]bool {
156 activated := make(map[string]bool)
157 for _, message := range messages {
158 for _, name := range message.AddedToolNames {
159 activated[name] = true
160 }
161 }
162 return activated
163}
164
165// Usage aggregates token counts and cost for a turn.
166type Usage struct {
167 InputTokens int `json:"input_tokens"`
168 OutputTokens int `json:"output_tokens"`
169 ReasoningTokens int `json:"reasoning_tokens"`
170 ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
171 CacheReadTokens int `json:"cache_read_tokens"`
172 CacheWriteTokens int `json:"cache_write_tokens"`
173 CostUSD float64 `json:"cost_usd"`
174}
175
176// Add returns u plus v. A reasoning total is known only when both
177// component usage reports include a separate reasoning-token count.
178func (u Usage) Add(v Usage) Usage {
179 return Usage{
180 InputTokens: u.InputTokens + v.InputTokens,
181 OutputTokens: u.OutputTokens + v.OutputTokens,
182 ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
183 ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
184 CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
185 CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
186 CostUSD: u.CostUSD + v.CostUSD,
187 }
188}
189
190// StopReason describes why a turn ended.
191type StopReason string
192
193const (
194 StopEnd StopReason = "end"
195 StopLength StopReason = "length"
196 StopToolUse StopReason = "tool_use"
197 StopError StopReason = "error"
198 StopAborted StopReason = "aborted"
199)
200
201// Event is one item from a provider stream.
202type Event interface {
203 isEvent()
204}
205
206type EventStart struct {
207 Model string
208 Provider string
209}
210
211func (EventStart) isEvent() {}
212
213type EventTextDelta struct {
214 Delta string
215}
216
217func (EventTextDelta) isEvent() {}
218
219type EventToolStart struct {
220 ID string
221 Name string
222}
223
224func (EventToolStart) isEvent() {}
225
226type EventToolArgs struct {
227 ID string
228 Delta string // partial JSON
229}
230
231func (EventToolArgs) isEvent() {}
232
233type EventToolEnd struct {
234 ID string
235}
236
237func (EventToolEnd) isEvent() {}
238
239type EventUsage struct {
240 Usage Usage
241}
242
243func (EventUsage) isEvent() {}
244
245// EventDone is always the final event on a stream.
246type EventDone struct {
247 Stop StopReason
248 Err error
249 Message Message // fully assembled assistant message
250}
251
252func (EventDone) isEvent() {}
253
254// Request is a single LLM call.
255type Request struct {
256 Model string
257 System string
258 Messages []Message
259 Tools []Tool
260 MaxTokens int
261 Temperature *float32
262 // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
263 // "max". Empty disables reasoning. The max tier is sent natively only to
264 // models that support it and clamped elsewhere.
265 Reasoning string
266 // SessionID, when set, is the conversation id. Providers that support
267 // sticky routing (OpenRouter session_id) forward it; others ignore it.
268 SessionID string
269 // MaxToolCalls, when set, caps OpenRouter's server-tool agent loop.
270 // Zero omits the field.
271 MaxToolCalls int
272}
273
274// Client is an LLM streaming client.
275type Client interface {
276 // Name returns "anthropic" or "openai".
277 Name() string
278 // Stream starts a request. The returned channel delivers events
279 // and is closed after EventDone. Errors during request setup are
280 // returned directly; runtime errors arrive as EventDone{Err: ...}.
281 Stream(ctx context.Context, req Request) (<-chan Event, error)
282}