Repository: sashabaranov/go-openai
Pull request #546 (2023-11-07 20:35:49)

Feat/messages api
This branch implements [thread messages API](https://platform.openai.com/docs/api-reference/messages)

Sibling to the threads api implementation https://github.com/sashabaranov/go-openai/pull/536

zhangpeihaoks: i real need this pl merge,this help me so more
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sashabaranov/go-openai/pull/546?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#546](https://app.codecov.io/gh/sashabaranov/go-openai/pull/546?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (6084633) into [master](https://app.codecov.io/gh/sashabaranov/go-openai/commit/a20eb08b79e5c34882888a401020b47c145357ff?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (a20eb08) will **decrease** coverage by `2.97%`.
> Report is 3 commits behind head on master.
> The diff coverage is `49.23%`.

```diff
@@            Coverage Diff             @@
##           master     #546      +/-   ##
==========================================
- Coverage   97.85%   94.88%   -2.97%     
==========================================
  Files          20       21       +1     
  Lines         978     1056      +78     
==========================================
+ Hits          957     1002      +45     
- Misses         15       37      +22     
- Partials        6       17      +11     
```


| [Files](https://app.codecov.io/gh/sashabaranov/go-openai/pull/546?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [messages.go](https://app.codecov.io/gh/sashabaranov/go-openai/pull/546?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-bWVzc2FnZXMuZ28=) | `49.23% <49.23%> (ø)` | |

... and [3 files with indirect coverage changes](https://app.codecov.io/gh/sashabaranov/go-openai/pull/546/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)


henomis: Hi, to cover your tests you need to add entries in `TestCases` in `client_test.go`
wti806: May I ask when the message API would be ready?
urjitbhatia: Thanks, I'll work on the fixes this weekend.

On Fri, Nov 10, 2023, 9:40 AM Ti Wang ***@***.***> wrote:

> May I ask when the message API would be ready?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/sashabaranov/go-openai/pull/546#issuecomment-1805960644>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AAJLOR344QSLRTGXNR2EFS3YDZDHTAVCNFSM6AAAAAA7BZOV7OVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMYTQMBVHE3DANRUGQ>
> .
> You are receiving this because you authored the thread.Message ID:
> ***@***.***>
>

urjitbhatia: > TestCases

Adding the test cases...

However, when looking at these tests - It makes sense to push for a high coverage but some of the work done to keep it high seems forced. For example, covering the branch when the API returns an error.

I'd suggest rethinking the requirement a little bit - it makes sense to cover the main logic but this test seem to add very little or no value - https://github.com/sashabaranov/go-openai/blob/master/client_test.go#L184 - am I missing something in the value of this test? It doesn't actually test that the request building failed due to bad/missing params, only that an `if err` was added to the calls - lmk if that's the intended use anyways.

@henomis @sashabaranov 

Patch:
diff --git a/messages.go b/messages.go
new file mode 100644
index 0000000..4e691a8
--- /dev/null
+++ b/messages.go
@@ -0,0 +1,178 @@
+package openai
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+)
+
+const (
+	messagesSuffix = "messages"
+)
+
+type Message struct {
+	ID          string           `json:"id"`
+	Object      string           `json:"object"`
+	CreatedAt   int              `json:"created_at"`
+	ThreadID    string           `json:"thread_id"`
+	Role        string           `json:"role"`
+	Content     []MessageContent `json:"content"`
+	FileIds     []string         `json:"file_ids"`
+	AssistantID *string          `json:"assistant_id,omitempty"`
+	RunID       *string          `json:"run_id,omitempty"`
+	Metadata    map[string]any   `json:"metadata"`
+
+	httpHeader
+}
+
+type MessagesList struct {
+	Messages []Message `json:"data"`
+
+	httpHeader
+}
+
+type MessageContent struct {
+	Type      string       `json:"type"`
+	Text      *MessageText `json:"text,omitempty"`
+	ImageFile *ImageFile   `json:"image_file,omitempty"`
+}
+type MessageText struct {
+	Value       string `json:"value"`
+	Annotations []any  `json:"annotations"`
+}
+
+type ImageFile struct {
+	FileID string `json:"file_id"`
+}
+
+type MessageRequest struct {
+	Role     string         `json:"role"`
+	Content  string         `json:"content"`
+	FileIds  []string       `json:"file_ids,omitempty"`
+	Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type MessageFile struct {
+	ID        string `json:"id"`
+	Object    string `json:"object"`
+	CreatedAt int    `json:"created_at"`
+	MessageID string `json:"message_id"`
+
+	httpHeader
+}
+
+type MessageFilesList struct {
+	MessageFiles []MessageFile `json:"data"`
+
+	httpHeader
+}
+
+// CreateMessage creates a new message.
+func (c *Client) CreateMessage(ctx context.Context, threadID string, request MessageRequest) (msg Message, err error) {
+	urlSuffix := fmt.Sprintf("/threads/%s/%s", threadID, messagesSuffix)
+	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(urlSuffix), withBody(request))
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &msg)
+	return
+}
+
+// ListMessage fetches all messages in the thread.
+func (c *Client) ListMessage(ctx context.Context, threadID string,
+	limit *int,
+	order *string,
+	after *string,
+	before *string,
+) (messages MessagesList, err error) {
+	urlValues := url.Values{}
+	if limit != nil {
+		urlValues.Add("limit", fmt.Sprintf("%d", *limit))
+	}
+	if order != nil {
+		urlValues.Add("order", *order)
+	}
+	if after != nil {
+		urlValues.Add("after", *after)
+	}
+	if before != nil {
+		urlValues.Add("before", *before)
+	}
+	encodedValues := ""
+	if len(urlValues) > 0 {
+		encodedValues = "?" + urlValues.Encode()
+	}
+
+	urlSuffix := fmt.Sprintf("/threads/%s/%s%s", threadID, messagesSuffix, encodedValues)
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix), withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &messages)
+	return
+}
+
+// RetrieveMessage retrieves a Message.
+func (c *Client) RetrieveMessage(
+	ctx context.Context,
+	threadID, messageID string,
+) (msg Message, err error) {
+	urlSuffix := fmt.Sprintf("/threads/%s/%s/%s", threadID, messagesSuffix, messageID)
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix), withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &msg)
+	return
+}
+
+// ModifyMessage modifies a message.
+func (c *Client) ModifyMessage(
+	ctx context.Context,
+	threadID, messageID string,
+	metadata map[string]any,
+) (msg Message, err error) {
+	urlSuffix := fmt.Sprintf("/threads/%s/%s/%s", threadID, messagesSuffix, messageID)
+	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(urlSuffix),
+		withBody(metadata), withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &msg)
+	return
+}
+
+// RetrieveMessageFile fetches a message file.
+func (c *Client) RetrieveMessageFile(
+	ctx context.Context,
+	threadID, messageID, fileID string,
+) (file MessageFile, err error) {
+	urlSuffix := fmt.Sprintf("/threads/%s/%s/%s/files/%s", threadID, messagesSuffix, messageID, fileID)
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix), withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &file)
+	return
+}
+
+// ListMessageFiles fetches all files attached to a message.
+func (c *Client) ListMessageFiles(
+	ctx context.Context,
+	threadID, messageID string,
+) (files MessageFilesList, err error) {
+	urlSuffix := fmt.Sprintf("/threads/%s/%s/%s/files", threadID, messagesSuffix, messageID)
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix), withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &files)
+	return
+}
diff --git a/run.go b/run.go
index 5d6ea58..7ff730f 100644
--- a/run.go
+++ b/run.go
@@ -70,11 +70,11 @@ const (
 )
 
 type RunRequest struct {
-	AssistantID  string  `json:"assistant_id"`
-	Model        *string `json:"model,omitempty"`
-	Instructions *string `json:"instructions,omitempty"`
-	Tools        []Tool  `json:"tools,omitempty"`
-	Metadata     map[string]any
+	AssistantID  string         `json:"assistant_id"`
+	Model        *string        `json:"model,omitempty"`
+	Instructions *string        `json:"instructions,omitempty"`
+	Tools        []Tool         `json:"tools,omitempty"`
+	Metadata     map[string]any `json:"metadata,omitempty"`
 }
 
 type RunModifyRequest struct {


Test patch:
diff --git a/chat_test.go b/chat_test.go
index a8155ed..8377809 100644
--- a/chat_test.go
+++ b/chat_test.go
@@ -144,7 +144,7 @@ func TestChatCompletionsWithRateLimitHeaders(t *testing.T) {
 	}
 	resetRequestsTime := headers.ResetRequests.Time()
 	if resetRequestsTime.Before(time.Now()) {
-		t.Errorf("unexpected reset requetsts: %v", resetRequestsTime)
+		t.Errorf("unexpected reset requests: %v", resetRequestsTime)
 	}
 
 	bs1, _ := json.Marshal(headers)
diff --git a/client_test.go b/client_test.go
index d5d3e26..24cb5ff 100644
--- a/client_test.go
+++ b/client_test.go
@@ -301,6 +301,24 @@ func TestClientReturnsRequestBuilderErrors(t *testing.T) {
 		{"DeleteAssistantFile", func() (any, error) {
 			return nil, client.DeleteAssistantFile(ctx, "", "")
 		}},
+		{"CreateMessage", func() (any, error) {
+			return client.CreateMessage(ctx, "", MessageRequest{})
+		}},
+		{"ListMessage", func() (any, error) {
+			return client.ListMessage(ctx, "", nil, nil, nil, nil)
+		}},
+		{"RetrieveMessage", func() (any, error) {
+			return client.RetrieveMessage(ctx, "", "")
+		}},
+		{"ModifyMessage", func() (any, error) {
+			return client.ModifyMessage(ctx, "", "", nil)
+		}},
+		{"RetrieveMessageFile", func() (any, error) {
+			return client.RetrieveMessageFile(ctx, "", "", "")
+		}},
+		{"ListMessageFiles", func() (any, error) {
+			return client.ListMessageFiles(ctx, "", "")
+		}},
 		{"CreateThread", func() (any, error) {
 			return client.CreateThread(ctx, ThreadRequest{})
 		}},
diff --git a/messages_test.go b/messages_test.go
new file mode 100644
index 0000000..282b1cc
--- /dev/null
+++ b/messages_test.go
@@ -0,0 +1,235 @@
+package openai_test
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/sashabaranov/go-openai"
+	"github.com/sashabaranov/go-openai/internal/test/checks"
+)
+
+var emptyStr = ""
+
+// TestMessages Tests the messages endpoint of the API using the mocked server.
+func TestMessages(t *testing.T) {
+	threadID := "thread_abc123"
+	messageID := "msg_abc123"
+	fileID := "file_abc123"
+
+	client, server, teardown := setupOpenAITestServer()
+	defer teardown()
+
+	server.RegisterHandler(
+		"/v1/threads/"+threadID+"/messages/"+messageID+"/files/"+fileID,
+		func(w http.ResponseWriter, r *http.Request) {
+			switch r.Method {
+			case http.MethodGet:
+				resBytes, _ := json.Marshal(
+					openai.MessageFile{
+						ID:        fileID,
+						Object:    "thread.message.file",
+						CreatedAt: 1699061776,
+						MessageID: messageID,
+					})
+				fmt.Fprintln(w, string(resBytes))
+			default:
+				t.Fatalf("unsupported messages http method: %s", r.Method)
+			}
+		},
+	)
+
+	server.RegisterHandler(
+		"/v1/threads/"+threadID+"/messages/"+messageID+"/files",
+		func(w http.ResponseWriter, r *http.Request) {
+			switch r.Method {
+			case http.MethodGet:
+				resBytes, _ := json.Marshal(
+					openai.MessageFilesList{MessageFiles: []openai.MessageFile{{
+						ID:        fileID,
+						Object:    "thread.message.file",
+						CreatedAt: 0,
+						MessageID: messageID,
+					}}})
+				fmt.Fprintln(w, string(resBytes))
+			default:
+				t.Fatalf("unsupported messages http method: %s", r.Method)
+			}
+		},
+	)
+
+	server.RegisterHandler(
+		"/v1/threads/"+threadID+"/messages/"+messageID,
+		func(w http.ResponseWriter, r *http.Request) {
+			switch r.Method {
+			case http.MethodPost:
+				metadata := map[string]any{}
+				err := json.NewDecoder(r.Body).Decode(&metadata)
+				checks.NoError(t, err, "unable to decode metadata in modify message call")
+
+				resBytes, _ := json.Marshal(
+					openai.Message{
+						ID:        messageID,
+						Object:    "thread.message",
+						CreatedAt: 1234567890,
+						ThreadID:  threadID,
+						Role:      "user",
+						Content: []openai.MessageContent{{
+							Type: "text",
+							Text: &openai.MessageText{
+								Value:       "How does AI work?",
+								Annotations: nil,
+							},
+						}},
+						FileIds:     nil,
+						AssistantID: &emptyStr,
+						RunID:       &emptyStr,
+						Metadata:    metadata,
+					})
+				fmt.Fprintln(w, string(resBytes))
+			case http.MethodGet:
+				resBytes, _ := json.Marshal(
+					openai.Message{
+						ID:        messageID,
+						Object:    "thread.message",
+						CreatedAt: 1234567890,
+						ThreadID:  threadID,
+						Role:      "user",
+						Content: []openai.MessageContent{{
+							Type: "text",
+							Text: &openai.MessageText{
+								Value:       "How does AI work?",
+								Annotations: nil,
+							},
+						}},
+						FileIds:     nil,
+						AssistantID: &emptyStr,
+						RunID:       &emptyStr,
+						Metadata:    nil,
+					})
+				fmt.Fprintln(w, string(resBytes))
+			default:
+				t.Fatalf("unsupported messages http method: %s", r.Method)
+			}
+		},
+	)
+
+	server.RegisterHandler(
+		"/v1/threads/"+threadID+"/messages",
+		func(w http.ResponseWriter, r *http.Request) {
+			switch r.Method {
+			case http.MethodPost:
+				resBytes, _ := json.Marshal(openai.Message{
+					ID:        messageID,
+					Object:    "thread.message",
+					CreatedAt: 1234567890,
+					ThreadID:  threadID,
+					Role:      "user",
+					Content: []openai.MessageContent{{
+						Type: "text",
+						Text: &openai.MessageText{
+							Value:       "How does AI work?",
+							Annotations: nil,
+						},
+					}},
+					FileIds:     nil,
+					AssistantID: &emptyStr,
+					RunID:       &emptyStr,
+					Metadata:    nil,
+				})
+				fmt.Fprintln(w, string(resBytes))
+			case http.MethodGet:
+				resBytes, _ := json.Marshal(openai.MessagesList{
+					Messages: []openai.Message{{
+						ID:        messageID,
+						Object:    "thread.message",
+						CreatedAt: 1234567890,
+						ThreadID:  threadID,
+						Role:      "user",
+						Content: []openai.MessageContent{{
+							Type: "text",
+							Text: &openai.MessageText{
+								Value:       "How does AI work?",
+								Annotations: nil,
+							},
+						}},
+						FileIds:     nil,
+						AssistantID: &emptyStr,
+						RunID:       &emptyStr,
+						Metadata:    nil,
+					}}})
+				fmt.Fprintln(w, string(resBytes))
+			default:
+				t.Fatalf("unsupported messages http method: %s", r.Method)
+			}
+		},
+	)
+
+	ctx := context.Background()
+
+	// static assertion of return type
+	var msg openai.Message
+	msg, err := client.CreateMessage(ctx, threadID, openai.MessageRequest{
+		Role:     "user",
+		Content:  "How does AI work?",
+		FileIds:  nil,
+		Metadata: nil,
+	})
+	checks.NoError(t, err, "CreateMessage error")
+	if msg.ID != messageID {
+		t.Fatalf("unexpected message id: '%s'", msg.ID)
+	}
+
+	var msgs openai.MessagesList
+	msgs, err = client.ListMessage(ctx, threadID, nil, nil, nil, nil)
+	checks.NoError(t, err, "ListMessages error")
+	if len(msgs.Messages) != 1 {
+		t.Fatalf("unexpected length of fetched messages")
+	}
+
+	// with pagination options set
+	limit := 1
+	order := "desc"
+	after := "obj_foo"
+	before := "obj_bar"
+	msgs, err = client.ListMessage(ctx, threadID, &limit, &order, &after, &before)
+	checks.NoError(t, err, "ListMessages error")
+	if len(msgs.Messages) != 1 {
+		t.Fatalf("unexpected length of fetched messages")
+	}
+
+	msg, err = client.RetrieveMessage(ctx, threadID, messageID)
+	checks.NoError(t, err, "RetrieveMessage error")
+	if msg.ID != messageID {
+		t.Fatalf("unexpected message id: '%s'", msg.ID)
+	}
+
+	msg, err = client.ModifyMessage(ctx, threadID, messageID,
+		map[string]any{
+			"foo": "bar",
+		})
+	checks.NoError(t, err, "ModifyMessage error")
+	if msg.Metadata["foo"] != "bar" {
+		t.Fatalf("expected message metadata to get modified")
+	}
+
+	// message files
+	var msgFile openai.MessageFile
+	msgFile, err = client.RetrieveMessageFile(ctx, threadID, messageID, fileID)
+	checks.NoError(t, err, "RetrieveMessageFile error")
+	if msgFile.ID != fileID {
+		t.Fatalf("unexpected message file id: '%s'", msgFile.ID)
+	}
+
+	var msgFiles openai.MessageFilesList
+	msgFiles, err = client.ListMessageFiles(ctx, threadID, messageID)
+	checks.NoError(t, err, "RetrieveMessageFile error")
+	if len(msgFiles.MessageFiles) != 1 {
+		t.Fatalf("unexpected count of message files: %d", len(msgFiles.MessageFiles))
+	}
+	if msgFiles.MessageFiles[0].ID != fileID {
+		t.Fatalf("unexpected message file id: '%s' in list message files", msgFiles.MessageFiles[0].ID)
+	}
+}