diff --git a/pkg/llm/chat.go b/pkg/llm/chat.go index 8aa7def9b..153737e7c 100644 --- a/pkg/llm/chat.go +++ b/pkg/llm/chat.go @@ -65,8 +65,8 @@ type ( FinishReason string Usage struct { - InputTokens int - OutputTokens int + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` } ChatCompletionResponse struct { diff --git a/pkg/llm/message.go b/pkg/llm/message.go index f8c5bfbc2..7b754c4e4 100644 --- a/pkg/llm/message.go +++ b/pkg/llm/message.go @@ -14,9 +14,11 @@ package llm -import "strings" - -import "encoding/json" +import ( + "encoding/json" + "fmt" + "strings" +) type ( Message struct { @@ -27,13 +29,13 @@ type ( } ToolCall struct { - ID string - Function FunctionCall + ID string `json:"id"` + Function FunctionCall `json:"function"` } FunctionCall struct { - Name string - Arguments string // JSON-encoded arguments + Name string `json:"name"` + Arguments string `json:"arguments"` } Tool struct { @@ -43,6 +45,124 @@ type ( } ) +type partEnvelope struct { + Type string `json:"type"` + // TextPart and ThinkingPart share the Text field. + Text string `json:"text,omitempty"` + // ImagePart fields + URL string `json:"url,omitempty"` + // FilePart fields + Data string `json:"data,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Filename string `json:"filename,omitempty"` + // ThinkingPart fields + Signature string `json:"signature,omitempty"` +} + +type messageJSON struct { + Role Role `json:"role"` + Parts []partEnvelope `json:"parts,omitempty"` + ToolCalls []toolCallJSON `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type toolCallJSON struct { + ID string `json:"id"` + Function functionCallJSON `json:"function"` +} + +type functionCallJSON struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +func (m Message) MarshalJSON() ([]byte, error) { + mj := messageJSON{ + Role: m.Role, + ToolCallID: m.ToolCallID, + } + + if len(m.ToolCalls) > 0 { + mj.ToolCalls = make([]toolCallJSON, len(m.ToolCalls)) + for i, tc := range m.ToolCalls { + mj.ToolCalls[i] = toolCallJSON{ + ID: tc.ID, + Function: functionCallJSON{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + } + } + } + + for _, p := range m.Parts { + switch v := p.(type) { + case TextPart: + mj.Parts = append(mj.Parts, partEnvelope{Type: "text", Text: v.Text}) + case ImagePart: + mj.Parts = append(mj.Parts, partEnvelope{Type: "image", URL: v.URL}) + case FilePart: + mj.Parts = append(mj.Parts, partEnvelope{ + Type: "file", Data: v.Data, MimeType: v.MimeType, Filename: v.Filename, + }) + case ThinkingPart: + mj.Parts = append(mj.Parts, partEnvelope{Type: "thinking", Text: v.Text, Signature: v.Signature}) + default: + return nil, fmt.Errorf("cannot marshal unknown Part type %T", p) + } + } + + return json.Marshal(mj) +} + +func (m *Message) UnmarshalJSON(data []byte) error { + var mj messageJSON + if err := json.Unmarshal(data, &mj); err != nil { + return err + } + + m.Role = mj.Role + m.ToolCallID = mj.ToolCallID + + if len(mj.ToolCalls) > 0 { + m.ToolCalls = make([]ToolCall, len(mj.ToolCalls)) + for i, tc := range mj.ToolCalls { + m.ToolCalls[i] = ToolCall{ + ID: tc.ID, + Function: FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + } + } + } else { + m.ToolCalls = nil + } + + if len(mj.Parts) == 0 { + m.Parts = nil + return nil + } + + m.Parts = make([]Part, len(mj.Parts)) + for i, env := range mj.Parts { + switch env.Type { + case "text": + m.Parts[i] = TextPart{Text: env.Text} + case "image": + m.Parts[i] = ImagePart{URL: env.URL} + case "file": + m.Parts[i] = FilePart{Data: env.Data, MimeType: env.MimeType, Filename: env.Filename} + case "thinking": + m.Parts[i] = ThinkingPart{Text: env.Text, Signature: env.Signature} + default: + return fmt.Errorf("cannot unmarshal unknown Part type %q", env.Type) + } + } + + return nil +} + func (m Message) Text() string { var s strings.Builder for _, p := range m.Parts { diff --git a/pkg/llm/message_test.go b/pkg/llm/message_test.go new file mode 100644 index 000000000..36ea11835 --- /dev/null +++ b/pkg/llm/message_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package llm + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMessageJSONRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + msg Message + }{ + { + name: "text only", + msg: Message{ + Role: RoleUser, + Parts: []Part{TextPart{Text: "hello"}}, + }, + }, + { + name: "image part", + msg: Message{ + Role: RoleUser, + Parts: []Part{ImagePart{URL: "https://example.com/img.png"}}, + }, + }, + { + name: "file part", + msg: Message{ + Role: RoleUser, + Parts: []Part{FilePart{ + Data: "aGVsbG8=", MimeType: "text/plain", Filename: "hello.txt", + }}, + }, + }, + { + name: "mixed parts", + msg: Message{ + Role: RoleUser, + Parts: []Part{ + TextPart{Text: "see attached"}, + FilePart{Data: "aGVsbG8=", MimeType: "text/plain", Filename: "hello.txt"}, + }, + }, + }, + { + name: "assistant with tool calls", + msg: Message{ + Role: RoleAssistant, + Parts: []Part{TextPart{Text: "calling tool"}}, + ToolCalls: []ToolCall{{ + ID: "call_1", + Function: FunctionCall{Name: "search", Arguments: `{"q":"test"}`}, + }}, + }, + }, + { + name: "tool response", + msg: Message{ + Role: RoleTool, + ToolCallID: "call_1", + Parts: []Part{TextPart{Text: "result"}}, + }, + }, + { + name: "empty parts", + msg: Message{Role: RoleAssistant}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(tt.msg) + require.NoError(t, err) + + var got Message + err = json.Unmarshal(data, &got) + require.NoError(t, err) + assert.Equal(t, tt.msg, got) + }) + } +} diff --git a/pkg/llm/part.go b/pkg/llm/part.go index 91f5add39..f798525f6 100644 --- a/pkg/llm/part.go +++ b/pkg/llm/part.go @@ -20,17 +20,17 @@ type ( } TextPart struct { - Text string + Text string `json:"text"` } ImagePart struct { - URL string + URL string `json:"url"` } FilePart struct { - Data string // base64-encoded content - MimeType string // e.g. "application/pdf", "text/csv", "image/png" - Filename string + Data string `json:"data"` // base64-encoded content + MimeType string `json:"mime_type"` // e.g. "application/pdf", "text/csv", "image/png" + Filename string `json:"filename"` } ThinkingPart struct {