blob: 2c1011eb42857881a9ee61f56907e15ebc047e31 [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.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -070093 Run func(ctx context.Context, input json.RawMessage) ToolOut `json:"-"`
94}
95
96// ToolOut represents the output of a tool run.
97type ToolOut struct {
98 // LLMContent is the output of the tool to be sent back to the LLM.
99 // May be nil on error.
100 LLMContent []Content
101 // Error is the error (if any) that occurred during the tool run.
102 // The text contents of the error will be sent back to the LLM.
103 // If non-nil, LLMContent will be ignored.
104 Error error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700105}
106
107type Content struct {
108 ID string
109 Type ContentType
110 Text string
111
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700112 // Media type for image content
113 MediaType string
114
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700115 // for thinking
116 Thinking string
117 Data string
118 Signature string
119
120 // for tool_use
121 ToolName string
122 ToolInput json.RawMessage
123
124 // for tool_result
125 ToolUseID string
126 ToolError bool
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700127 ToolResult []Content
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700128
129 // timing information for tool_result; added externally; not sent to the LLM
130 ToolUseStartTime *time.Time
131 ToolUseEndTime *time.Time
132
133 Cache bool
134}
135
136func StringContent(s string) Content {
137 return Content{Type: ContentTypeText, Text: s}
138}
139
140// ContentsAttr returns contents as a slog.Attr.
141// It is meant for logging.
142func ContentsAttr(contents []Content) slog.Attr {
143 var contentAttrs []any // slog.Attr
144 for _, content := range contents {
145 var attrs []any // slog.Attr
146 switch content.Type {
147 case ContentTypeText:
148 attrs = append(attrs, slog.String("text", content.Text))
149 case ContentTypeToolUse:
150 attrs = append(attrs, slog.String("tool_name", content.ToolName))
151 attrs = append(attrs, slog.String("tool_input", string(content.ToolInput)))
152 case ContentTypeToolResult:
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700153 attrs = append(attrs, slog.Any("tool_result", content.ToolResult))
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700154 attrs = append(attrs, slog.Bool("tool_error", content.ToolError))
155 case ContentTypeThinking:
156 attrs = append(attrs, slog.String("thinking", content.Text))
157 default:
158 attrs = append(attrs, slog.String("unknown_content_type", content.Type.String()))
159 attrs = append(attrs, slog.Any("text", content)) // just log it all raw, better to have too much than not enough
160 }
161 contentAttrs = append(contentAttrs, slog.Group(content.ID, attrs...))
162 }
163 return slog.Group("contents", contentAttrs...)
164}
165
166type (
167 MessageRole int
168 ContentType int
169 ToolChoiceType int
170 StopReason int
171)
172
173//go:generate go tool golang.org/x/tools/cmd/stringer -type=MessageRole,ContentType,ToolChoiceType,StopReason -output=llm_string.go
174
175const (
176 MessageRoleUser MessageRole = iota
177 MessageRoleAssistant
178
179 ContentTypeText ContentType = iota
180 ContentTypeThinking
181 ContentTypeRedactedThinking
182 ContentTypeToolUse
183 ContentTypeToolResult
184
185 ToolChoiceTypeAuto ToolChoiceType = iota // default
186 ToolChoiceTypeAny // any tool, but must use one
187 ToolChoiceTypeNone // no tools allowed
188 ToolChoiceTypeTool // must use the tool specified in the Name field
189
190 StopReasonStopSequence StopReason = iota
191 StopReasonMaxTokens
192 StopReasonEndTurn
193 StopReasonToolUse
Josh Bleecher Snyder0e8073a2025-05-22 21:04:51 -0700194 StopReasonRefusal
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700195)
196
197type Response struct {
198 ID string
199 Type string
200 Role MessageRole
201 Model string
202 Content []Content
203 StopReason StopReason
204 StopSequence *string
205 Usage Usage
206 StartTime *time.Time
207 EndTime *time.Time
208}
209
210func (m *Response) ToMessage() Message {
211 return Message{
212 Role: m.Role,
213 Content: m.Content,
214 }
215}
216
Josh Bleecher Snyder59bb27d2025-06-05 07:32:10 -0700217func CostUSDFromResponse(headers http.Header) float64 {
218 h := headers.Get("Skaband-Cost-Microcents")
219 if h == "" {
220 return 0
221 }
222 uc, err := strconv.ParseUint(h, 10, 64)
223 if err != nil {
224 slog.Warn("failed to parse cost header", "header", h)
225 return 0
226 }
227 return float64(uc) / 100_000_000
228}
229
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700230// Usage represents the billing and rate-limit usage.
231// Most LLM structs do not have JSON tags, to avoid accidental direct use in specific providers.
232// However, the front-end uses this struct, and it relies on its JSON serialization.
233// Do NOT use this struct directly when implementing an llm.Service.
234type Usage struct {
235 InputTokens uint64 `json:"input_tokens"`
236 CacheCreationInputTokens uint64 `json:"cache_creation_input_tokens"`
237 CacheReadInputTokens uint64 `json:"cache_read_input_tokens"`
238 OutputTokens uint64 `json:"output_tokens"`
239 CostUSD float64 `json:"cost_usd"`
240}
241
242func (u *Usage) Add(other Usage) {
243 u.InputTokens += other.InputTokens
244 u.CacheCreationInputTokens += other.CacheCreationInputTokens
245 u.CacheReadInputTokens += other.CacheReadInputTokens
246 u.OutputTokens += other.OutputTokens
247 u.CostUSD += other.CostUSD
248}
249
250func (u *Usage) String() string {
251 return fmt.Sprintf("in: %d, out: %d", u.InputTokens, u.OutputTokens)
252}
253
254func (u *Usage) IsZero() bool {
255 return *u == Usage{}
256}
257
258func (u *Usage) Attr() slog.Attr {
259 return slog.Group("usage",
260 slog.Uint64("input_tokens", u.InputTokens),
261 slog.Uint64("output_tokens", u.OutputTokens),
262 slog.Uint64("cache_creation_input_tokens", u.CacheCreationInputTokens),
263 slog.Uint64("cache_read_input_tokens", u.CacheReadInputTokens),
264 slog.Float64("cost_usd", u.CostUSD),
265 )
266}
267
268// UserStringMessage creates a user message with a single text content item.
269func UserStringMessage(text string) Message {
270 return Message{
271 Role: MessageRoleUser,
272 Content: []Content{StringContent(text)},
273 }
274}
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700275
276// TextContent creates a simple text content for tool results.
277// This is a helper function to create the most common type of tool result content.
278func TextContent(text string) []Content {
279 return []Content{{
280 Type: ContentTypeText,
281 Text: text,
282 }}
283}
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -0700284
285func ErrorToolOut(err error) ToolOut {
286 if err == nil {
287 panic("ErrorToolOut called with nil error")
288 }
289 return ToolOut{
290 Error: err,
291 }
292}
293
294func ErrorfToolOut(format string, args ...any) ToolOut {
295 return ErrorToolOut(fmt.Errorf(format, args...))
296}