blob: 638a6a35c22a88c2a98942f7dcbd761b890eccbb [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
103 // Error is the error (if any) that occurred during the tool run.
104 // The text contents of the error will be sent back to the LLM.
105 // If non-nil, LLMContent will be ignored.
106 Error error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700107}
108
109type Content struct {
110 ID string
111 Type ContentType
112 Text string
113
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700114 // Media type for image content
115 MediaType string
116
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700117 // for thinking
118 Thinking string
119 Data string
120 Signature string
121
122 // for tool_use
123 ToolName string
124 ToolInput json.RawMessage
125
126 // for tool_result
127 ToolUseID string
128 ToolError bool
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700129 ToolResult []Content
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700130
131 // timing information for tool_result; added externally; not sent to the LLM
132 ToolUseStartTime *time.Time
133 ToolUseEndTime *time.Time
134
135 Cache bool
136}
137
138func StringContent(s string) Content {
139 return Content{Type: ContentTypeText, Text: s}
140}
141
142// ContentsAttr returns contents as a slog.Attr.
143// It is meant for logging.
144func ContentsAttr(contents []Content) slog.Attr {
145 var contentAttrs []any // slog.Attr
146 for _, content := range contents {
147 var attrs []any // slog.Attr
148 switch content.Type {
149 case ContentTypeText:
150 attrs = append(attrs, slog.String("text", content.Text))
151 case ContentTypeToolUse:
152 attrs = append(attrs, slog.String("tool_name", content.ToolName))
153 attrs = append(attrs, slog.String("tool_input", string(content.ToolInput)))
154 case ContentTypeToolResult:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700155 attrs = append(attrs, slog.Any("tool_result", content.ToolResult))
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700156 attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
157 case ContentTypeThinking:
158 attrs = append(attrs, slog.String("thinking", content.Text))
159 default:
160 attrs = append(attrs, slog.String("unknown_content_type", content.Type.String()))
161 attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
162 }
163 contentAttrs = append(contentAttrs, slog.Group(content.ID, attrs...))
164 }
165 return slog.Group("contents", contentAttrs...)
166}
167
168type (
169 MessageRole int
170 ContentType int
171 ToolChoiceType int
172 StopReason int
173)
174
175//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go
176
177const (
178 MessageRoleUser MessageRole = iota
179 MessageRoleAssistant
180
181 ContentTypeText ContentType = iota
182 ContentTypeThinking
183 ContentTypeRedactedThinking
184 ContentTypeToolUse
185 ContentTypeToolResult
186
187 ToolChoiceTypeAuto ToolChoiceType = iota // default
188 ToolChoiceTypeAny // any tool, but must use one
189 ToolChoiceTypeNone // no tools allowed
190 ToolChoiceTypeTool // must use the tool specified in the Name field
191
192 StopReasonStopSequence StopReason = iota
193 StopReasonMaxTokens
194 StopReasonEndTurn
195 StopReasonToolUse
Josh Bleecher Snyder0e8073a2025-05-22 21:04:51 -0700196 StopReasonRefusal
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700197)
198
199type Response struct {
200 ID string
201 Type string
202 Role MessageRole
203 Model string
204 Content []Content
205 StopReason StopReason
206 StopSequence *string
207 Usage Usage
208 StartTime *time.Time
209 EndTime *time.Time
210}
211
212func (m *Response) ToMessage() Message {
213 return Message{
214 Role: m.Role,
215 Content: m.Content,
216 }
217}
218
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -0700219func CostUSDFromResponse(headers http.Header) float64 {
220 h := headers.Get("Skaband-Cost-Microcents")
221 if h == "" {
222 return 0
223 }
224 uc, err := strconv.ParseUint(h, 10, 64)
225 if err != nil {
226 slog.Warn("failed to parse cost header", "header", h)
227 return 0
228 }
229 return float64(uc) / 100_000_000
230}
231
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700232// Usage represents the billing and rate-limit usage.
233// Most LLM structs do not have JSON tags, to avoid accidental direct use in specific providers.
234// However, the front-end uses this struct, and it relies on its JSON serialization.
235// Do NOT use this struct directly when implementing an llm.Service.
236type Usage struct {
237 InputTokens uint64 `json:"input_tokens"`
238 CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
239 CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
240 OutputTokens uint64 `json:"output_tokens"`
241 CostUSD float64 `json:"cost_usd"`
242}
243
244func (u *Usage) Add(other Usage) {
245 u.InputTokens += other.InputTokens
246 u.CacheCreationInputTokens += other.CacheCreationInputTokens
247 u.CacheReadInputTokens += other.CacheReadInputTokens
248 u.OutputTokens += other.OutputTokens
249 u.CostUSD += other.CostUSD
250}
251
252func (u *Usage) String() string {
253 return fmt.Sprintf("in: %d, out: %d", u.InputTokens, u.OutputTokens)
254}
255
256func (u *Usage) IsZero() bool {
257 return *u == Usage{}
258}
259
260func (u *Usage) Attr() slog.Attr {
261 return slog.Group("usage",
262 slog.Uint64("input_tokens", u.InputTokens),
263 slog.Uint64("output_tokens", u.OutputTokens),
264 slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
265 slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
266 slog.Float64("cost_usd", u.CostUSD),
267 )
268}
269
270// UserStringMessage creates a user message with a single text content item.
271func UserStringMessage(text string) Message {
272 return Message{
273 Role: MessageRoleUser,
274 Content: []Content{StringContent(text)},
275 }
276}
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700277
278// TextContent creates a simple text content for tool results.
279// This is a helper function to create the most common type of tool result content.
280func TextContent(text string) []Content {
281 return []Content{{
282 Type: ContentTypeText,
283 Text: text,
284 }}
285}
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700286
287func ErrorToolOut(err error) ToolOut {
288 if err == nil {
289 panic("ErrorToolOut called with nil error")
290 }
291 return ToolOut{
292 Error: err,
293 }
294}
295
296func ErrorfToolOut(format string, args ...any) ToolOut {
297 return ErrorToolOut(fmt.Errorf(format, args...))
298}
Josh Bleecher Snyder57afbca2025-07-23 13:29:59 -0700299
300// DumpToFile writes LLM communication content to a timestamped file in ~/.cache/sketch/.
301// For requests, it includes the URL followed by the content. For responses, it only includes the content.
302// The typ parameter is used as a prefix in the filename ("request", "response").
303func DumpToFile(typ string, url string, content []byte) error {
304 homeDir, err := os.UserHomeDir()
305 if err != nil {
306 return err
307 }
308 cacheDir := filepath.Join(homeDir, ".cache", "sketch")
309 err = os.MkdirAll(cacheDir, 0o700)
310 if err != nil {
311 return err
312 }
313 now := time.Now()
314 filename := fmt.Sprintf("%s_%d.txt", typ, now.UnixMilli())
315 filePath := filepath.Join(cacheDir, filename)
316
317 // For requests, start with the URL; for responses, just write the content
318 data := []byte(url)
319 if url != "" {
320 data = append(data, "\n\n"...)
321 }
322 data = append(data, content...)
323
324 return os.WriteFile(filePath, data, 0o600)
325}