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