v0.19.0 d7aa01a

provider.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close