blob: 3192ba912e9f2799436496e2f8201cc0c5b94fef [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"
10 "strconv"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070011 "strings"
12 "time"
13)
14
15type Service interface {
16 // Do sends a request to an LLM.
17 Do(context.Context, *Request) (*Response, error)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070018 // TokenContextWindow returns the maximum token context window size for this service
19 TokenContextWindow() int
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070020}
21
22// MustSchema validates that schema is a valid JSON schema and returns it as a json.RawMessage.
23// It panics if the schema is invalid.
Josh Bleecher Snyder2e967e52025-07-14 21:09:31 +000024// The schema must have at least type="object" and a properties key.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070025func MustSchema(schema string) json.RawMessage {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070026 schema = strings.TrimSpace(schema)
27 bytes := []byte(schema)
Josh Bleecher Snyder2e967e52025-07-14 21:09:31 +000028 var obj map[string]any
29 if err := json.Unmarshal(bytes, &obj); err != nil {
30 panic("failed to parse JSON schema: " + schema + ": " + err.Error())
31 }
32 if typ, ok := obj["type"]; !ok || typ != "object" {
33 panic("JSON schema must have type='object': " + schema)
34 }
35 if _, ok := obj["properties"]; !ok {
36 panic("JSON schema must have 'properties' key: " + schema)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070037 }
38 return json.RawMessage(bytes)
39}
40
Josh Bleecher Snyder74d690e2025-05-14 18:16:03 -070041func EmptySchema() json.RawMessage {
42 return MustSchema(`{"type": "object", "properties": {}}`)
43}
44
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070045type Request struct {
46 Messages []Message
47 ToolChoice *ToolChoice
48 Tools []*Tool
49 System []SystemContent
50}
51
52// Message represents a message in the conversation.
53type Message struct {
54 Role MessageRole
55 Content []Content
56 ToolUse *ToolUse // use to control whether/which tool to use
57}
58
59// ToolUse represents a tool use in the message content.
60type ToolUse struct {
61 ID string
62 Name string
63}
64
65type ToolChoice struct {
66 Type ToolChoiceType
67 Name string
68}
69
70type SystemContent struct {
71 Text string
72 Type string
73 Cache bool
74}
75
76// Tool represents a tool available to an LLM.
77type Tool struct {
78 Name string
79 // Type is used by the text editor tool; see
80 // https://docs.anthropic.com/en/docs/build-with-claude/tool-use/text-editor-tool
81 Type string
82 Description string
83 InputSchema json.RawMessage
Sean McCullough021557a2025-05-05 23:20:53 +000084 // EndsTurn indicates that this tool should cause the model to end its turn when used
85 EndsTurn bool
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070086
87 // The Run function is automatically called when the tool is used.
88 // Run functions may be called concurrently with each other and themselves.
89 // The input to Run function is the input to the tool, as provided by Claude, in compliance with the input schema.
90 // The outputs from Run will be sent back to Claude.
91 // If you do not want to respond to the tool call request from Claude, return ErrDoNotRespond.
92 // ctx contains extra (rarely used) tool call information; retrieve it with ToolCallInfoFromContext.
Philip Zeyliger72252cb2025-05-10 17:00:08 -070093 Run func(ctx context.Context, input json.RawMessage) ([]Content, error) `json:"-"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070094}
95
96type Content struct {
97 ID string
98 Type ContentType
99 Text string
100
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700101 // Media type for image content
102 MediaType string
103
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700104 // for thinking
105 Thinking string
106 Data string
107 Signature string
108
109 // for tool_use
110 ToolName string
111 ToolInput json.RawMessage
112
113 // for tool_result
114 ToolUseID string
115 ToolError bool
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700116 ToolResult []Content
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700117
118 // timing information for tool_result; added externally; not sent to the LLM
119 ToolUseStartTime *time.Time
120 ToolUseEndTime *time.Time
121
122 Cache bool
123}
124
125func StringContent(s string) Content {
126 return Content{Type: ContentTypeText, Text: s}
127}
128
129// ContentsAttr returns contents as a slog.Attr.
130// It is meant for logging.
131func ContentsAttr(contents []Content) slog.Attr {
132 var contentAttrs []any // slog.Attr
133 for _, content := range contents {
134 var attrs []any // slog.Attr
135 switch content.Type {
136 case ContentTypeText:
137 attrs = append(attrs, slog.String("text", content.Text))
138 case ContentTypeToolUse:
139 attrs = append(attrs, slog.String("tool_name", content.ToolName))
140 attrs = append(attrs, slog.String("tool_input", string(content.ToolInput)))
141 case ContentTypeToolResult:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700142 attrs = append(attrs, slog.Any("tool_result", content.ToolResult))
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700143 attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
144 case ContentTypeThinking:
145 attrs = append(attrs, slog.String("thinking", content.Text))
146 default:
147 attrs = append(attrs, slog.String("unknown_content_type", content.Type.String()))
148 attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
149 }
150 contentAttrs = append(contentAttrs, slog.Group(content.ID, attrs...))
151 }
152 return slog.Group("contents", contentAttrs...)
153}
154
155type (
156 MessageRole int
157 ContentType int
158 ToolChoiceType int
159 StopReason int
160)
161
162//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go
163
164const (
165 MessageRoleUser MessageRole = iota
166 MessageRoleAssistant
167
168 ContentTypeText ContentType = iota
169 ContentTypeThinking
170 ContentTypeRedactedThinking
171 ContentTypeToolUse
172 ContentTypeToolResult
173
174 ToolChoiceTypeAuto ToolChoiceType = iota // default
175 ToolChoiceTypeAny // any tool, but must use one
176 ToolChoiceTypeNone // no tools allowed
177 ToolChoiceTypeTool // must use the tool specified in the Name field
178
179 StopReasonStopSequence StopReason = iota
180 StopReasonMaxTokens
181 StopReasonEndTurn
182 StopReasonToolUse
Josh Bleecher Snyder0e8073a2025-05-22 21:04:51 -0700183 StopReasonRefusal
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700184)
185
186type Response struct {
187 ID string
188 Type string
189 Role MessageRole
190 Model string
191 Content []Content
192 StopReason StopReason
193 StopSequence *string
194 Usage Usage
195 StartTime *time.Time
196 EndTime *time.Time
197}
198
199func (m *Response) ToMessage() Message {
200 return Message{
201 Role: m.Role,
202 Content: m.Content,
203 }
204}
205
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -0700206func CostUSDFromResponse(headers http.Header) float64 {
207 h := headers.Get("Skaband-Cost-Microcents")
208 if h == "" {
209 return 0
210 }
211 uc, err := strconv.ParseUint(h, 10, 64)
212 if err != nil {
213 slog.Warn("failed to parse cost header", "header", h)
214 return 0
215 }
216 return float64(uc) / 100_000_000
217}
218
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700219// Usage represents the billing and rate-limit usage.
220// Most LLM structs do not have JSON tags, to avoid accidental direct use in specific providers.
221// However, the front-end uses this struct, and it relies on its JSON serialization.
222// Do NOT use this struct directly when implementing an llm.Service.
223type Usage struct {
224 InputTokens uint64 `json:"input_tokens"`
225 CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
226 CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
227 OutputTokens uint64 `json:"output_tokens"`
228 CostUSD float64 `json:"cost_usd"`
229}
230
231func (u *Usage) Add(other Usage) {
232 u.InputTokens += other.InputTokens
233 u.CacheCreationInputTokens += other.CacheCreationInputTokens
234 u.CacheReadInputTokens += other.CacheReadInputTokens
235 u.OutputTokens += other.OutputTokens
236 u.CostUSD += other.CostUSD
237}
238
239func (u *Usage) String() string {
240 return fmt.Sprintf("in: %d, out: %d", u.InputTokens, u.OutputTokens)
241}
242
243func (u *Usage) IsZero() bool {
244 return *u == Usage{}
245}
246
247func (u *Usage) Attr() slog.Attr {
248 return slog.Group("usage",
249 slog.Uint64("input_tokens", u.InputTokens),
250 slog.Uint64("output_tokens", u.OutputTokens),
251 slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
252 slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
253 slog.Float64("cost_usd", u.CostUSD),
254 )
255}
256
257// UserStringMessage creates a user message with a single text content item.
258func UserStringMessage(text string) Message {
259 return Message{
260 Role: MessageRoleUser,
261 Content: []Content{StringContent(text)},
262 }
263}
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700264
265// TextContent creates a simple text content for tool results.
266// This is a helper function to create the most common type of tool result content.
267func TextContent(text string) []Content {
268 return []Content{{
269 Type: ContentTypeText,
270 Text: text,
271 }}
272}