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