1package message
2
3import (
4 "encoding/base64"
5 "errors"
6 "slices"
7 "strings"
8 "time"
9
10 "charm.land/fantasy"
11 "charm.land/fantasy/providers/anthropic"
12 "charm.land/fantasy/providers/google"
13 "charm.land/fantasy/providers/openai"
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
15)
16
17type MessageRole string
18
19const (
20 Assistant MessageRole = "assistant"
21 User MessageRole = "user"
22 System MessageRole = "system"
23 Tool MessageRole = "tool"
24)
25
26type FinishReason string
27
28const (
29 FinishReasonEndTurn FinishReason = "end_turn"
30 FinishReasonMaxTokens FinishReason = "max_tokens"
31 FinishReasonToolUse FinishReason = "tool_use"
32 FinishReasonCanceled FinishReason = "canceled"
33 FinishReasonError FinishReason = "error"
34 FinishReasonPermissionDenied FinishReason = "permission_denied"
35
36 // Should never happen
37 FinishReasonUnknown FinishReason = "unknown"
38)
39
40type ContentPart interface {
41 isPart()
42}
43
44type ReasoningContent struct {
45 Thinking string `json:"thinking"`
46 Signature string `json:"signature"`
47 ThoughtSignature string `json:"thought_signature"` // Used for google
48 ToolID string `json:"tool_id"` // Used for openrouter google models
49 ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
50 StartedAt int64 `json:"started_at,omitempty"`
51 FinishedAt int64 `json:"finished_at,omitempty"`
52}
53
54func (tc ReasoningContent) String() string {
55 return tc.Thinking
56}
57func (ReasoningContent) isPart() {}
58
59type TextContent struct {
60 Text string `json:"text"`
61}
62
63func (tc TextContent) String() string {
64 return tc.Text
65}
66
67func (TextContent) isPart() {}
68
69type ImageURLContent struct {
70 URL string `json:"url"`
71 Detail string `json:"detail,omitempty"`
72}
73
74func (iuc ImageURLContent) String() string {
75 return iuc.URL
76}
77
78func (ImageURLContent) isPart() {}
79
80type BinaryContent struct {
81 Path string
82 MIMEType string
83 Data []byte
84}
85
86func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
87 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
88 if p == catwalk.InferenceProviderOpenAI {
89 return "data:" + bc.MIMEType + ";base64," + base64Encoded
90 }
91 return base64Encoded
92}
93
94func (BinaryContent) isPart() {}
95
96type ToolCall struct {
97 ID string `json:"id"`
98 Name string `json:"name"`
99 Input string `json:"input"`
100 ProviderExecuted bool `json:"provider_executed"`
101 Finished bool `json:"finished"`
102}
103
104func (ToolCall) isPart() {}
105
106type ToolResult struct {
107 ToolCallID string `json:"tool_call_id"`
108 Name string `json:"name"`
109 Content string `json:"content"`
110 Data string `json:"data"`
111 MIMEType string `json:"mime_type"`
112 Metadata string `json:"metadata"`
113 IsError bool `json:"is_error"`
114}
115
116func (ToolResult) isPart() {}
117
118type Finish struct {
119 Reason FinishReason `json:"reason"`
120 Time int64 `json:"time"`
121 Message string `json:"message,omitempty"`
122 Details string `json:"details,omitempty"`
123}
124
125func (Finish) isPart() {}
126
127type Message struct {
128 ID string
129 Role MessageRole
130 SessionID string
131 Parts []ContentPart
132 Model string
133 Provider string
134 CreatedAt int64
135 UpdatedAt int64
136 IsSummaryMessage bool
137}
138
139func (m *Message) Content() TextContent {
140 for _, part := range m.Parts {
141 if c, ok := part.(TextContent); ok {
142 return c
143 }
144 }
145 return TextContent{}
146}
147
148func (m *Message) ReasoningContent() ReasoningContent {
149 for _, part := range m.Parts {
150 if c, ok := part.(ReasoningContent); ok {
151 return c
152 }
153 }
154 return ReasoningContent{}
155}
156
157func (m *Message) ImageURLContent() []ImageURLContent {
158 imageURLContents := make([]ImageURLContent, 0)
159 for _, part := range m.Parts {
160 if c, ok := part.(ImageURLContent); ok {
161 imageURLContents = append(imageURLContents, c)
162 }
163 }
164 return imageURLContents
165}
166
167func (m *Message) BinaryContent() []BinaryContent {
168 binaryContents := make([]BinaryContent, 0)
169 for _, part := range m.Parts {
170 if c, ok := part.(BinaryContent); ok {
171 binaryContents = append(binaryContents, c)
172 }
173 }
174 return binaryContents
175}
176
177func (m *Message) ToolCalls() []ToolCall {
178 toolCalls := make([]ToolCall, 0)
179 for _, part := range m.Parts {
180 if c, ok := part.(ToolCall); ok {
181 toolCalls = append(toolCalls, c)
182 }
183 }
184 return toolCalls
185}
186
187func (m *Message) ToolResults() []ToolResult {
188 toolResults := make([]ToolResult, 0)
189 for _, part := range m.Parts {
190 if c, ok := part.(ToolResult); ok {
191 toolResults = append(toolResults, c)
192 }
193 }
194 return toolResults
195}
196
197func (m *Message) IsFinished() bool {
198 for _, part := range m.Parts {
199 if _, ok := part.(Finish); ok {
200 return true
201 }
202 }
203 return false
204}
205
206func (m *Message) FinishPart() *Finish {
207 for _, part := range m.Parts {
208 if c, ok := part.(Finish); ok {
209 return &c
210 }
211 }
212 return nil
213}
214
215func (m *Message) FinishReason() FinishReason {
216 for _, part := range m.Parts {
217 if c, ok := part.(Finish); ok {
218 return c.Reason
219 }
220 }
221 return ""
222}
223
224func (m *Message) IsThinking() bool {
225 if m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished() {
226 return true
227 }
228 return false
229}
230
231func (m *Message) AppendContent(delta string) {
232 found := false
233 for i, part := range m.Parts {
234 if c, ok := part.(TextContent); ok {
235 m.Parts[i] = TextContent{Text: c.Text + delta}
236 found = true
237 }
238 }
239 if !found {
240 m.Parts = append(m.Parts, TextContent{Text: delta})
241 }
242}
243
244func (m *Message) AppendReasoningContent(delta string) {
245 found := false
246 for i, part := range m.Parts {
247 if c, ok := part.(ReasoningContent); ok {
248 m.Parts[i] = ReasoningContent{
249 Thinking: c.Thinking + delta,
250 Signature: c.Signature,
251 StartedAt: c.StartedAt,
252 FinishedAt: c.FinishedAt,
253 }
254 found = true
255 }
256 }
257 if !found {
258 m.Parts = append(m.Parts, ReasoningContent{
259 Thinking: delta,
260 StartedAt: time.Now().Unix(),
261 })
262 }
263}
264
265func (m *Message) AppendThoughtSignature(signature string, toolCallID string) {
266 for i, part := range m.Parts {
267 if c, ok := part.(ReasoningContent); ok {
268 m.Parts[i] = ReasoningContent{
269 Thinking: c.Thinking,
270 ThoughtSignature: c.ThoughtSignature + signature,
271 ToolID: toolCallID,
272 Signature: c.Signature,
273 StartedAt: c.StartedAt,
274 FinishedAt: c.FinishedAt,
275 }
276 return
277 }
278 }
279 m.Parts = append(m.Parts, ReasoningContent{ThoughtSignature: signature})
280}
281
282func (m *Message) AppendReasoningSignature(signature string) {
283 for i, part := range m.Parts {
284 if c, ok := part.(ReasoningContent); ok {
285 m.Parts[i] = ReasoningContent{
286 Thinking: c.Thinking,
287 Signature: c.Signature + signature,
288 StartedAt: c.StartedAt,
289 FinishedAt: c.FinishedAt,
290 }
291 return
292 }
293 }
294 m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
295}
296
297func (m *Message) SetReasoningResponsesData(data *openai.ResponsesReasoningMetadata) {
298 for i, part := range m.Parts {
299 if c, ok := part.(ReasoningContent); ok {
300 m.Parts[i] = ReasoningContent{
301 Thinking: c.Thinking,
302 ResponsesData: data,
303 StartedAt: c.StartedAt,
304 FinishedAt: c.FinishedAt,
305 }
306 return
307 }
308 }
309}
310
311func (m *Message) FinishThinking() {
312 for i, part := range m.Parts {
313 if c, ok := part.(ReasoningContent); ok {
314 if c.FinishedAt == 0 {
315 m.Parts[i] = ReasoningContent{
316 Thinking: c.Thinking,
317 Signature: c.Signature,
318 StartedAt: c.StartedAt,
319 FinishedAt: time.Now().Unix(),
320 }
321 }
322 return
323 }
324 }
325}
326
327func (m *Message) ThinkingDuration() time.Duration {
328 reasoning := m.ReasoningContent()
329 if reasoning.StartedAt == 0 {
330 return 0
331 }
332
333 endTime := reasoning.FinishedAt
334 if endTime == 0 {
335 endTime = time.Now().Unix()
336 }
337
338 return time.Duration(endTime-reasoning.StartedAt) * time.Second
339}
340
341func (m *Message) FinishToolCall(toolCallID string) {
342 for i, part := range m.Parts {
343 if c, ok := part.(ToolCall); ok {
344 if c.ID == toolCallID {
345 m.Parts[i] = ToolCall{
346 ID: c.ID,
347 Name: c.Name,
348 Input: c.Input,
349 Finished: true,
350 }
351 return
352 }
353 }
354 }
355}
356
357func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
358 for i, part := range m.Parts {
359 if c, ok := part.(ToolCall); ok {
360 if c.ID == toolCallID {
361 m.Parts[i] = ToolCall{
362 ID: c.ID,
363 Name: c.Name,
364 Input: c.Input + inputDelta,
365 Finished: c.Finished,
366 }
367 return
368 }
369 }
370 }
371}
372
373func (m *Message) AddToolCall(tc ToolCall) {
374 for i, part := range m.Parts {
375 if c, ok := part.(ToolCall); ok {
376 if c.ID == tc.ID {
377 m.Parts[i] = tc
378 return
379 }
380 }
381 }
382 m.Parts = append(m.Parts, tc)
383}
384
385func (m *Message) SetToolCalls(tc []ToolCall) {
386 // remove any existing tool call part it could have multiple
387 parts := make([]ContentPart, 0)
388 for _, part := range m.Parts {
389 if _, ok := part.(ToolCall); ok {
390 continue
391 }
392 parts = append(parts, part)
393 }
394 m.Parts = parts
395 for _, toolCall := range tc {
396 m.Parts = append(m.Parts, toolCall)
397 }
398}
399
400func (m *Message) AddToolResult(tr ToolResult) {
401 m.Parts = append(m.Parts, tr)
402}
403
404func (m *Message) SetToolResults(tr []ToolResult) {
405 for _, toolResult := range tr {
406 m.Parts = append(m.Parts, toolResult)
407 }
408}
409
410func (m *Message) AddFinish(reason FinishReason, message, details string) {
411 // remove any existing finish part
412 for i, part := range m.Parts {
413 if _, ok := part.(Finish); ok {
414 m.Parts = slices.Delete(m.Parts, i, i+1)
415 break
416 }
417 }
418 m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
419}
420
421func (m *Message) AddImageURL(url, detail string) {
422 m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
423}
424
425func (m *Message) AddBinary(mimeType string, data []byte) {
426 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
427}
428
429func (m *Message) ToAIMessage() []fantasy.Message {
430 var messages []fantasy.Message
431 switch m.Role {
432 case User:
433 var parts []fantasy.MessagePart
434 text := strings.TrimSpace(m.Content().Text)
435 if text != "" {
436 parts = append(parts, fantasy.TextPart{Text: text})
437 }
438 for _, content := range m.BinaryContent() {
439 parts = append(parts, fantasy.FilePart{
440 Filename: content.Path,
441 Data: content.Data,
442 MediaType: content.MIMEType,
443 })
444 }
445 messages = append(messages, fantasy.Message{
446 Role: fantasy.MessageRoleUser,
447 Content: parts,
448 })
449 case Assistant:
450 var parts []fantasy.MessagePart
451 text := strings.TrimSpace(m.Content().Text)
452 if text != "" {
453 parts = append(parts, fantasy.TextPart{Text: text})
454 }
455 reasoning := m.ReasoningContent()
456 if reasoning.Thinking != "" {
457 reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
458 if reasoning.Signature != "" {
459 reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
460 Signature: reasoning.Signature,
461 }
462 }
463 if reasoning.ResponsesData != nil {
464 reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
465 }
466 if reasoning.ThoughtSignature != "" {
467 reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
468 Signature: reasoning.ThoughtSignature,
469 ToolID: reasoning.ToolID,
470 }
471 }
472 parts = append(parts, reasoningPart)
473 }
474 for _, call := range m.ToolCalls() {
475 parts = append(parts, fantasy.ToolCallPart{
476 ToolCallID: call.ID,
477 ToolName: call.Name,
478 Input: call.Input,
479 ProviderExecuted: call.ProviderExecuted,
480 })
481 }
482 messages = append(messages, fantasy.Message{
483 Role: fantasy.MessageRoleAssistant,
484 Content: parts,
485 })
486 case Tool:
487 var parts []fantasy.MessagePart
488 for _, result := range m.ToolResults() {
489 var content fantasy.ToolResultOutputContent
490 if result.IsError {
491 content = fantasy.ToolResultOutputContentError{
492 Error: errors.New(result.Content),
493 }
494 } else if result.Data != "" {
495 content = fantasy.ToolResultOutputContentMedia{
496 Data: result.Data,
497 MediaType: result.MIMEType,
498 }
499 } else {
500 content = fantasy.ToolResultOutputContentText{
501 Text: result.Content,
502 }
503 }
504 parts = append(parts, fantasy.ToolResultPart{
505 ToolCallID: result.ToolCallID,
506 Output: content,
507 })
508 }
509 messages = append(messages, fantasy.Message{
510 Role: fantasy.MessageRoleTool,
511 Content: parts,
512 })
513 }
514 return messages
515}