v0.38.1 969e52a

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

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