v0.21.0 cd9706d

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

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