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