blob: ffaad3e517d5e005d8c7c7d2008f497e8f6f6ccf [file] [log] [blame]
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001// Package llm provides a unified interface for interacting with LLMs.
2package llm
3
4import (
5 "context"
6 "encoding/json"
7 "fmt"
8 "log/slog"
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -07009 "net/http"
Josh Bleecher Snyder57afbca2025-07-23 13:29:59 -070010 "os"
11 "path/filepath"
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -070012 "strconv"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070013 "strings"
14 "time"
15)
16
17type Service interface {
18 // Do sends a request to an LLM.
19 Do(context.Context, *Request) (*Response, error)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070020 // TokenContextWindow returns the maximum token context window size for this service
21 TokenContextWindow() int
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070022}
23
24// MustSchema validates that schema is a valid JSON schema and returns it as a json.RawMessage.
25// It panics if the schema is invalid.
Josh Bleecher Snyder2e967e52025-07-14 21:09:31 +000026// The schema must have at least type="object" and a properties key.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070027func MustSchema(schema string) json.RawMessage {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 schema = strings.TrimSpace(schema)
29 bytes := []byte(schema)
Josh Bleecher Snyder2e967e52025-07-14 21:09:31 +000030 var obj map[string]any
31 if err := json.Unmarshal(bytes, &obj); err != nil {
32 panic("failed to parse JSON schema: " + schema + ": " + err.Error())
33 }
34 if typ, ok := obj["type"]; !ok || typ != "object" {
35 panic("JSON schema must have type='object': " + schema)
36 }
37 if _, ok := obj["properties"]; !ok {
38 panic("JSON schema must have 'properties' key: " + schema)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070039 }
40 return json.RawMessage(bytes)
41}
42
Josh Bleecher Snyder74d690e2025-05-14 18:16:03 -070043func EmptySchema() json.RawMessage {
44 return MustSchema(`{"type": "object", "properties": {}}`)
45}
46
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070047type Request struct {
48 Messages []Message
49 ToolChoice *ToolChoice
50 Tools []*Tool
51 System []SystemContent
52}
53
54// Message represents a message in the conversation.
55type Message struct {
56 Role MessageRole
57 Content []Content
58 ToolUse *ToolUse // use to control whether/which tool to use
59}
60
61// ToolUse represents a tool use in the message content.
62type ToolUse struct {
63 ID string
64 Name string
65}
66
67type ToolChoice struct {
68 Type ToolChoiceType
69 Name string
70}
71
72type SystemContent struct {
73 Text string
74 Type string
75 Cache bool
76}
77
78// Tool represents a tool available to an LLM.
79type Tool struct {
80 Name string
81 // Type is used by the text editor tool; see
82 // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
83 Type string
84 Description string
85 InputSchema json.RawMessage
Sean McCullough021557a2025-05-05 23:20:53 +000086 // EndsTurn indicates that this tool should cause the model to end its turn when used
87 EndsTurn bool
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070088
89 // The Run function is automatically called when the tool is used.
90 // Run functions may be called concurrently with each other and themselves.
91 // The input to Run function is the input to the tool, as provided by Claude, in compliance with the input schema.
92 // The outputs from Run will be sent back to Claude.
93 // If you do not want to respond to the tool call request from Claude, return ErrDoNotRespond.
94 // ctx contains extra (rarely used) tool call information; retrieve it with ToolCallInfoFromContext.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -070095 Run func(ctx context.Context, input json.RawMessage) ToolOut `json:"-"`
96}
97
98// ToolOut represents the output of a tool run.
99type ToolOut struct {
100 // LLMContent is the output of the tool to be sent back to the LLM.
101 // May be nil on error.
102 LLMContent []Content
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700103 // Display is content to be displayed to the user.
104 // The type of content is set by the tool and coordinated with the UIs.
105 // It should be JSON-serializable.
106 Display any
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700107 // Error is the error (if any) that occurred during the tool run.
108 // The text contents of the error will be sent back to the LLM.
109 // If non-nil, LLMContent will be ignored.
110 Error error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700111}
112
113type Content struct {
114 ID string
115 Type ContentType
116 Text string
117
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700118 // Media type for image content
119 MediaType string
120
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700121 // for thinking
122 Thinking string
123 Data string
124 Signature string
125
126 // for tool_use
127 ToolName string
128 ToolInput json.RawMessage
129
130 // for tool_result
131 ToolUseID string
132 ToolError bool
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700133 ToolResult []Content
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700134
135 // timing information for tool_result; added externally; not sent to the LLM
136 ToolUseStartTime *time.Time
137 ToolUseEndTime *time.Time
138
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700139 // Display is content to be displayed to the user, copied from ToolOut
140 Display any
141
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700142 Cache bool
143}
144
145func StringContent(s string) Content {
146 return Content{Type: ContentTypeText, Text: s}
147}
148
149// ContentsAttr returns contents as a slog.Attr.
150// It is meant for logging.
151func ContentsAttr(contents []Content) slog.Attr {
152 var contentAttrs []any // slog.Attr
153 for _, content := range contents {
154 var attrs []any // slog.Attr
155 switch content.Type {
156 case ContentTypeText:
157 attrs = append(attrs, slog.String("text", content.Text))
158 case ContentTypeToolUse:
159 attrs = append(attrs, slog.String("tool_name", content.ToolName))
160 attrs = append(attrs, slog.String("tool_input", string(content.ToolInput)))
161 case ContentTypeToolResult:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700162 attrs = append(attrs, slog.Any("tool_result", content.ToolResult))
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700163 attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
164 case ContentTypeThinking:
165 attrs = append(attrs, slog.String("thinking", content.Text))
166 default:
167 attrs = append(attrs, slog.String("unknown_content_type", content.Type.String()))
168 attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
169 }
170 contentAttrs = append(contentAttrs, slog.Group(content.ID, attrs...))
171 }
172 return slog.Group("contents", contentAttrs...)
173}
174
175type (
176 MessageRole int
177 ContentType int
178 ToolChoiceType int
179 StopReason int
180)
181
182//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go
183
184const (
185 MessageRoleUser MessageRole = iota
186 MessageRoleAssistant
187
188 ContentTypeText ContentType = iota
189 ContentTypeThinking
190 ContentTypeRedactedThinking
191 ContentTypeToolUse
192 ContentTypeToolResult
193
194 ToolChoiceTypeAuto ToolChoiceType = iota // default
195 ToolChoiceTypeAny // any tool, but must use one
196 ToolChoiceTypeNone // no tools allowed
197 ToolChoiceTypeTool // must use the tool specified in the Name field
198
199 StopReasonStopSequence StopReason = iota
200 StopReasonMaxTokens
201 StopReasonEndTurn
202 StopReasonToolUse
Josh Bleecher Snyder0e8073a2025-05-22 21:04:51 -0700203 StopReasonRefusal
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700204)
205
206type Response struct {
207 ID string
208 Type string
209 Role MessageRole
210 Model string
211 Content []Content
212 StopReason StopReason
213 StopSequence *string
214 Usage Usage
215 StartTime *time.Time
216 EndTime *time.Time
217}
218
219func (m *Response) ToMessage() Message {
220 return Message{
221 Role: m.Role,
222 Content: m.Content,
223 }
224}
225
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -0700226func CostUSDFromResponse(headers http.Header) float64 {
227 h := headers.Get("Skaband-Cost-Microcents")
228 if h == "" {
229 return 0
230 }
231 uc, err := strconv.ParseUint(h, 10, 64)
232 if err != nil {
233 slog.Warn("failed to parse cost header", "header", h)
234 return 0
235 }
236 return float64(uc) / 100_000_000
237}
238
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700239// Usage represents the billing and rate-limit usage.
240// Most LLM structs do not have JSON tags, to avoid accidental direct use in specific providers.
241// However, the front-end uses this struct, and it relies on its JSON serialization.
242// Do NOT use this struct directly when implementing an llm.Service.
243type Usage struct {
244 InputTokens uint64 `json:"input_tokens"`
245 CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
246 CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
247 OutputTokens uint64 `json:"output_tokens"`
248 CostUSD float64 `json:"cost_usd"`
249}
250
251func (u *Usage) Add(other Usage) {
252 u.InputTokens += other.InputTokens
253 u.CacheCreationInputTokens += other.CacheCreationInputTokens
254 u.CacheReadInputTokens += other.CacheReadInputTokens
255 u.OutputTokens += other.OutputTokens
256 u.CostUSD += other.CostUSD
257}
258
259func (u *Usage) String() string {
260 return fmt.Sprintf("in: %d, out: %d", u.InputTokens, u.OutputTokens)
261}
262
263func (u *Usage) IsZero() bool {
264 return *u == Usage{}
265}
266
267func (u *Usage) Attr() slog.Attr {
268 return slog.Group("usage",
269 slog.Uint64("input_tokens", u.InputTokens),
270 slog.Uint64("output_tokens", u.OutputTokens),
271 slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
272 slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
273 slog.Float64("cost_usd", u.CostUSD),
274 )
275}
276
277// UserStringMessage creates a user message with a single text content item.
278func UserStringMessage(text string) Message {
279 return Message{
280 Role: MessageRoleUser,
281 Content: []Content{StringContent(text)},
282 }
283}
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700284
285// TextContent creates a simple text content for tool results.
286// This is a helper function to create the most common type of tool result content.
287func TextContent(text string) []Content {
288 return []Content{{
289 Type: ContentTypeText,
290 Text: text,
291 }}
292}
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700293
294func ErrorToolOut(err error) ToolOut {
295 if err == nil {
296 panic("ErrorToolOut called with nil error")
297 }
298 return ToolOut{
299 Error: err,
300 }
301}
302
303func ErrorfToolOut(format string, args ...any) ToolOut {
304 return ErrorToolOut(fmt.Errorf(format, args...))
305}
Josh Bleecher Snyder57afbca2025-07-23 13:29:59 -0700306
307// DumpToFile writes LLM communication content to a timestamped file in ~/.cache/sketch/.
308// For requests, it includes the URL followed by the content. For responses, it only includes the content.
309// The typ parameter is used as a prefix in the filename ("request", "response").
310func DumpToFile(typ string, url string, content []byte) error {
311 homeDir, err := os.UserHomeDir()
312 if err != nil {
313 return err
314 }
315 cacheDir := filepath.Join(homeDir, ".cache", "sketch")
316 err = os.MkdirAll(cacheDir, 0o700)
317 if err != nil {
318 return err
319 }
320 now := time.Now()
321 filename := fmt.Sprintf("%s_%d.txt", typ, now.UnixMilli())
322 filePath := filepath.Join(cacheDir, filename)
323
324 // For requests, start with the URL; for responses, just write the content
325 data := []byte(url)
326 if url != "" {
327 data = append(data, "\n\n"...)
328 }
329 data = append(data, content...)
330
331 return os.WriteFile(filePath, data, 0o600)
332}