content.go

  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}