Repository: sashabaranov/go-openai
Pull request #528 (2023-11-07 02:20:58)

feat: initial TTS support
**Describe the change**
Adds very basic support for `create_speech` endpoint. 

See also https://platform.openai.com/docs/api-reference/audio/createSpeech and https://platform.openai.com/docs/guides/text-to-speech.

**Describe your solution**
This is an extremely minimal approach that returns the http body (`io.ReadCloser`). An additional method that saves the result directly to a file is likely to be useful. For streaming, having the raw chunks is most helpful. The test includes an example for saving to file.

I've added the relevant constants, and the basic API should work, could use more tests for sure. 

**Tests**
I likely don't have time to write the necessary tests. I've added a quick and dirty integration test that succeeds - it synthesizes some text and saves to mp3. Listening to the mp3 confirms that the audio is correct.

It would be useful for somebody to write the rest of the mock methods and add a test mp3 file to return.

**Additional context**
Saving to a particular file format is trivial, but streaming particular forms of audio is tricky and I want to leave that to the end user. Most implementations will require additional format-specific libraries, no need for that in this library.



codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sashabaranov/go-openai/pull/528?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#528](https://app.codecov.io/gh/sashabaranov/go-openai/pull/528?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (78d9526) into [master](https://app.codecov.io/gh/sashabaranov/go-openai/commit/d6f3bdcdac9172ab5248d6be8c3e1761446a434c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (d6f3bdc) will **decrease** coverage by `0.24%`.
> The diff coverage is `72.72%`.

```diff
@@            Coverage Diff             @@
##           master     #528      +/-   ##
==========================================
- Coverage   98.22%   97.98%   -0.24%     
==========================================
  Files          22       23       +1     
  Lines        1181     1192      +11     
==========================================
+ Hits         1160     1168       +8     
- Misses         15       17       +2     
- Partials        6        7       +1     
```


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


stillmatic: I feel like the error check that codecov is complaining should be covered by the code here?
Petrzlen: > I feel like the error check that codecov is complaining should be covered by the code here?

Thanks for the PR! I am giving out a shot. 

Regarding your codecov question
```
speech.go#L50-L51

Added lines #L50 - L51 were not covered by tests
```
then the computer is correct. 

Your test returns an error from the server request handler `response, err = c.sendRequestRaw(req)`;
whereas the coverage complains about `req, err := c.newRequest(ctx, http.MethodPost, c.fullURL("/audio/speech", request.Model)`

I am new to this codebase - it's awesome though - so unsure if even should be tested.


stillmatic: hi @nguyendc-systran -- think this should be good to review!

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 {
diff --git a/speech.go b/speech.go
new file mode 100644
index 0000000..a3d5f5d
--- /dev/null
+++ b/speech.go
@@ -0,0 +1,87 @@
+package openai
+
+import (
+	"context"
+	"errors"
+	"io"
+	"net/http"
+)
+
+type SpeechModel string
+
+const (
+	TTSModel1   SpeechModel = "tts-1"
+	TTsModel1HD SpeechModel = "tts-1-hd"
+)
+
+type SpeechVoice string
+
+const (
+	VoiceAlloy   SpeechVoice = "alloy"
+	VoiceEcho    SpeechVoice = "echo"
+	VoiceFable   SpeechVoice = "fable"
+	VoiceOnyx    SpeechVoice = "onyx"
+	VoiceNova    SpeechVoice = "nova"
+	VoiceShimmer SpeechVoice = "shimmer"
+)
+
+type SpeechResponseFormat string
+
+const (
+	SpeechResponseFormatMp3  SpeechResponseFormat = "mp3"
+	SpeechResponseFormatOpus SpeechResponseFormat = "opus"
+	SpeechResponseFormatAac  SpeechResponseFormat = "aac"
+	SpeechResponseFormatFlac SpeechResponseFormat = "flac"
+)
+
+var (
+	ErrInvalidSpeechModel = errors.New("invalid speech model")
+	ErrInvalidVoice       = errors.New("invalid voice")
+)
+
+type CreateSpeechRequest struct {
+	Model          SpeechModel          `json:"model"`
+	Input          string               `json:"input"`
+	Voice          SpeechVoice          `json:"voice"`
+	ResponseFormat SpeechResponseFormat `json:"response_format,omitempty"` // Optional, default to mp3
+	Speed          float64              `json:"speed,omitempty"`           // Optional, default to 1.0
+}
+
+func contains[T comparable](s []T, e T) bool {
+	for _, v := range s {
+		if v == e {
+			return true
+		}
+	}
+	return false
+}
+
+func isValidSpeechModel(model SpeechModel) bool {
+	return contains([]SpeechModel{TTSModel1, TTsModel1HD}, model)
+}
+
+func isValidVoice(voice SpeechVoice) bool {
+	return contains([]SpeechVoice{VoiceAlloy, VoiceEcho, VoiceFable, VoiceOnyx, VoiceNova, VoiceShimmer}, voice)
+}
+
+func (c *Client) CreateSpeech(ctx context.Context, request CreateSpeechRequest) (response io.ReadCloser, err error) {
+	if !isValidSpeechModel(request.Model) {
+		err = ErrInvalidSpeechModel
+		return
+	}
+	if !isValidVoice(request.Voice) {
+		err = ErrInvalidVoice
+		return
+	}
+	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL("/audio/speech", request.Model),
+		withBody(request),
+		withContentType("application/json; charset=utf-8"),
+	)
+	if err != nil {
+		return
+	}
+
+	response, err = c.sendRequestRaw(req)
+
+	return
+}


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..1c90845 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{})
 		}},
@@ -340,6 +358,9 @@ func TestClientReturnsRequestBuilderErrors(t *testing.T) {
 		{"ListRunSteps", func() (any, error) {
 			return client.ListRunSteps(ctx, "", "", Pagination{})
 		}},
+		{"CreateSpeech", func() (any, error) {
+			return client.CreateSpeech(ctx, CreateSpeechRequest{Model: TTSModel1, Voice: VoiceAlloy})
+		}},
 	}
 
 	for _, testCase := range testCases {
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)
+	}
+}
diff --git a/speech_test.go b/speech_test.go
new file mode 100644
index 0000000..d9ba58b
--- /dev/null
+++ b/speech_test.go
@@ -0,0 +1,115 @@
+package openai_test
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"mime"
+	"net/http"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/sashabaranov/go-openai"
+	"github.com/sashabaranov/go-openai/internal/test"
+	"github.com/sashabaranov/go-openai/internal/test/checks"
+)
+
+func TestSpeechIntegration(t *testing.T) {
+	client, server, teardown := setupOpenAITestServer()
+	defer teardown()
+
+	server.RegisterHandler("/v1/audio/speech", func(w http.ResponseWriter, r *http.Request) {
+		dir, cleanup := test.CreateTestDirectory(t)
+		path := filepath.Join(dir, "fake.mp3")
+		test.CreateTestFile(t, path)
+		defer cleanup()
+
+		// audio endpoints only accept POST requests
+		if r.Method != "POST" {
+			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+
+		mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
+		if err != nil {
+			http.Error(w, "failed to parse media type", http.StatusBadRequest)
+			return
+		}
+
+		if mediaType != "application/json" {
+			http.Error(w, "request is not json", http.StatusBadRequest)
+			return
+		}
+
+		// Parse the JSON body of the request
+		var params map[string]interface{}
+		err = json.NewDecoder(r.Body).Decode(&params)
+		if err != nil {
+			http.Error(w, "failed to parse request body", http.StatusBadRequest)
+			return
+		}
+
+		// Check if each required field is present in the parsed JSON object
+		reqParams := []string{"model", "input", "voice"}
+		for _, param := range reqParams {
+			_, ok := params[param]
+			if !ok {
+				http.Error(w, fmt.Sprintf("no %s in params", param), http.StatusBadRequest)
+				return
+			}
+		}
+
+		// read audio file content
+		audioFile, err := os.ReadFile(path)
+		if err != nil {
+			http.Error(w, "failed to read audio file", http.StatusInternalServerError)
+			return
+		}
+
+		// write audio file content to response
+		w.Header().Set("Content-Type", "audio/mpeg")
+		w.Header().Set("Transfer-Encoding", "chunked")
+		w.Header().Set("Connection", "keep-alive")
+		_, err = w.Write(audioFile)
+		if err != nil {
+			http.Error(w, "failed to write body", http.StatusInternalServerError)
+			return
+		}
+	})
+
+	t.Run("happy path", func(t *testing.T) {
+		res, err := client.CreateSpeech(context.Background(), openai.CreateSpeechRequest{
+			Model: openai.TTSModel1,
+			Input: "Hello!",
+			Voice: openai.VoiceAlloy,
+		})
+		checks.NoError(t, err, "CreateSpeech error")
+		defer res.Close()
+
+		buf, err := io.ReadAll(res)
+		checks.NoError(t, err, "ReadAll error")
+
+		// save buf to file as mp3
+		err = os.WriteFile("test.mp3", buf, 0644)
+		checks.NoError(t, err, "Create error")
+	})
+	t.Run("invalid model", func(t *testing.T) {
+		_, err := client.CreateSpeech(context.Background(), openai.CreateSpeechRequest{
+			Model: "invalid_model",
+			Input: "Hello!",
+			Voice: openai.VoiceAlloy,
+		})
+		checks.ErrorIs(t, err, openai.ErrInvalidSpeechModel, "CreateSpeech error")
+	})
+
+	t.Run("invalid voice", func(t *testing.T) {
+		_, err := client.CreateSpeech(context.Background(), openai.CreateSpeechRequest{
+			Model: openai.TTSModel1,
+			Input: "Hello!",
+			Voice: "invalid_voice",
+		})
+		checks.ErrorIs(t, err, openai.ErrInvalidVoice, "CreateSpeech error")
+	})
+}