Repository: sashabaranov/go-openai
Pull request #549 (2023-11-07 22:53:43)

fix test server setup:
- go map access is not deterministic
- this can lead to a route: /foo/bar/1 matching /foo/bar before matching /foo/bar/1 if the map iteration go through /foo/bar first since the regex match wasn't bound to start and end anchors
- registering handlers now converts * in routes to .* for proper regex matching
- test server route handling now tries to fully match the handler route

This makes testing less flaky


Patch:
diff --git a/assistant.go b/assistant.go
index d75eebe..de49be6 100644
--- a/assistant.go
+++ b/assistant.go
@@ -10,46 +10,43 @@ import (
 const (
 	assistantsSuffix      = "/assistants"
 	assistantsFilesSuffix = "/files"
+	openaiAssistantsV1    = "assistants=v1"
 )
 
 type Assistant struct {
-	ID           string  `json:"id"`
-	Object       string  `json:"object"`
-	CreatedAt    int64   `json:"created_at"`
-	Name         *string `json:"name,omitempty"`
-	Description  *string `json:"description,omitempty"`
-	Model        string  `json:"model"`
-	Instructions *string `json:"instructions,omitempty"`
-	Tools        []any   `json:"tools,omitempty"`
+	ID           string          `json:"id"`
+	Object       string          `json:"object"`
+	CreatedAt    int64           `json:"created_at"`
+	Name         *string         `json:"name,omitempty"`
+	Description  *string         `json:"description,omitempty"`
+	Model        string          `json:"model"`
+	Instructions *string         `json:"instructions,omitempty"`
+	Tools        []AssistantTool `json:"tools,omitempty"`
 
 	httpHeader
 }
 
-type AssistantTool struct {
-	Type string `json:"type"`
-}
-
-type AssistantToolCodeInterpreter struct {
-	AssistantTool
-}
+type AssistantToolType string
 
-type AssistantToolRetrieval struct {
-	AssistantTool
-}
+const (
+	AssistantToolTypeCodeInterpreter AssistantToolType = "code_interpreter"
+	AssistantToolTypeRetrieval       AssistantToolType = "retrieval"
+	AssistantToolTypeFunction        AssistantToolType = "function"
+)
 
-type AssistantToolFunction struct {
-	AssistantTool
-	Function FunctionDefinition `json:"function"`
+type AssistantTool struct {
+	Type     AssistantToolType   `json:"type"`
+	Function *FunctionDefinition `json:"function,omitempty"`
 }
 
 type AssistantRequest struct {
-	Model        string         `json:"model"`
-	Name         *string        `json:"name,omitempty"`
-	Description  *string        `json:"description,omitempty"`
-	Instructions *string        `json:"instructions,omitempty"`
-	Tools        []any          `json:"tools,omitempty"`
-	FileIDs      []string       `json:"file_ids,omitempty"`
-	Metadata     map[string]any `json:"metadata,omitempty"`
+	Model        string          `json:"model"`
+	Name         *string         `json:"name,omitempty"`
+	Description  *string         `json:"description,omitempty"`
+	Instructions *string         `json:"instructions,omitempty"`
+	Tools        []AssistantTool `json:"tools,omitempty"`
+	FileIDs      []string        `json:"file_ids,omitempty"`
+	Metadata     map[string]any  `json:"metadata,omitempty"`
 }
 
 // AssistantsList is a list of assistants.
@@ -59,6 +56,14 @@ type AssistantsList struct {
 	httpHeader
 }
 
+type AssistantDeleteResponse struct {
+	ID      string `json:"id"`
+	Object  string `json:"object"`
+	Deleted bool   `json:"deleted"`
+
+	httpHeader
+}
+
 type AssistantFile struct {
 	ID          string `json:"id"`
 	Object      string `json:"object"`
@@ -80,7 +85,8 @@ type AssistantFilesList struct {
 
 // CreateAssistant creates a new assistant.
 func (c *Client) CreateAssistant(ctx context.Context, request AssistantRequest) (response Assistant, err error) {
-	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(assistantsSuffix), withBody(request))
+	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(assistantsSuffix), withBody(request),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -95,7 +101,8 @@ func (c *Client) RetrieveAssistant(
 	assistantID string,
 ) (response Assistant, err error) {
 	urlSuffix := fmt.Sprintf("%s/%s", assistantsSuffix, assistantID)
-	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix))
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -111,7 +118,8 @@ func (c *Client) ModifyAssistant(
 	request AssistantRequest,
 ) (response Assistant, err error) {
 	urlSuffix := fmt.Sprintf("%s/%s", assistantsSuffix, assistantID)
-	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(urlSuffix), withBody(request))
+	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(urlSuffix), withBody(request),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -124,9 +132,10 @@ func (c *Client) ModifyAssistant(
 func (c *Client) DeleteAssistant(
 	ctx context.Context,
 	assistantID string,
-) (response Assistant, err error) {
+) (response AssistantDeleteResponse, err error) {
 	urlSuffix := fmt.Sprintf("%s/%s", assistantsSuffix, assistantID)
-	req, err := c.newRequest(ctx, http.MethodDelete, c.fullURL(urlSuffix))
+	req, err := c.newRequest(ctx, http.MethodDelete, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -163,7 +172,8 @@ func (c *Client) ListAssistants(
 	}
 
 	urlSuffix := fmt.Sprintf("%s%s", assistantsSuffix, encodedValues)
-	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix))
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -180,7 +190,8 @@ func (c *Client) CreateAssistantFile(
 ) (response AssistantFile, err error) {
 	urlSuffix := fmt.Sprintf("%s/%s%s", assistantsSuffix, assistantID, assistantsFilesSuffix)
 	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(urlSuffix),
-		withBody(request))
+		withBody(request),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -196,7 +207,8 @@ func (c *Client) RetrieveAssistantFile(
 	fileID string,
 ) (response AssistantFile, err error) {
 	urlSuffix := fmt.Sprintf("%s/%s%s/%s", assistantsSuffix, assistantID, assistantsFilesSuffix, fileID)
-	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix))
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -212,7 +224,8 @@ func (c *Client) DeleteAssistantFile(
 	fileID string,
 ) (err error) {
 	urlSuffix := fmt.Sprintf("%s/%s%s/%s", assistantsSuffix, assistantID, assistantsFilesSuffix, fileID)
-	req, err := c.newRequest(ctx, http.MethodDelete, c.fullURL(urlSuffix))
+	req, err := c.newRequest(ctx, http.MethodDelete, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
@@ -250,7 +263,8 @@ func (c *Client) ListAssistantFiles(
 	}
 
 	urlSuffix := fmt.Sprintf("%s/%s%s%s", assistantsSuffix, assistantID, assistantsFilesSuffix, encodedValues)
-	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix))
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
 	if err != nil {
 		return
 	}
diff --git a/chat.go b/chat.go
index 609e0c3..9ad31c4 100644
--- a/chat.go
+++ b/chat.go
@@ -62,11 +62,17 @@ type ChatCompletionMessage struct {
 	Name string `json:"name,omitempty"`
 
 	FunctionCall *FunctionCall `json:"function_call,omitempty"`
-	ToolCalls    []ToolCall    `json:"tool_calls,omitempty"`
+
+	// For Role=assistant prompts this may be set to the tool calls generated by the model, such as function calls.
+	ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+
+	// For Role=tool prompts this should be set to the ID given in the assistant's prior request to call a tool.
+	ToolCallID string `json:"tool_call_id,omitempty"`
 }
 
 type ToolCall struct {
 	ID       string       `json:"id"`
+	Type     ToolType     `json:"type"`
 	Function FunctionCall `json:"function"`
 }
 
@@ -84,7 +90,7 @@ const (
 )
 
 type ChatCompletionResponseFormat struct {
-	Type ChatCompletionResponseFormatType `json:"type"`
+	Type ChatCompletionResponseFormatType `json:"type,omitempty"`
 }
 
 // ChatCompletionRequest represents a request structure for chat completion API.
@@ -112,7 +118,7 @@ type ChatCompletionRequest struct {
 	FunctionCall any    `json:"function_call,omitempty"`
 	Tools        []Tool `json:"tools,omitempty"`
 	// This can be either a string or an ToolChoice object.
-	ToolChoiche any `json:"tool_choice,omitempty"`
+	ToolChoice any `json:"tool_choice,omitempty"`
 }
 
 type ToolType string
@@ -126,7 +132,7 @@ type Tool struct {
 	Function FunctionDefinition `json:"function,omitempty"`
 }
 
-type ToolChoiche struct {
+type ToolChoice struct {
 	Type     ToolType     `json:"type"`
 	Function ToolFunction `json:"function,omitempty"`
 }
@@ -182,12 +188,13 @@ type ChatCompletionChoice struct {
 
 // ChatCompletionResponse represents a response structure for chat completion API.
 type ChatCompletionResponse struct {
-	ID      string                 `json:"id"`
-	Object  string                 `json:"object"`
-	Created int64                  `json:"created"`
-	Model   string                 `json:"model"`
-	Choices []ChatCompletionChoice `json:"choices"`
-	Usage   Usage                  `json:"usage"`
+	ID                string                 `json:"id"`
+	Object            string                 `json:"object"`
+	Created           int64                  `json:"created"`
+	Model             string                 `json:"model"`
+	Choices           []ChatCompletionChoice `json:"choices"`
+	Usage             Usage                  `json:"usage"`
+	SystemFingerprint string                 `json:"system_fingerprint"`
 
 	httpHeader
 }
diff --git a/client.go b/client.go
index 65ece81..056226c 100644
--- a/client.go
+++ b/client.go
@@ -83,6 +83,12 @@ func withContentType(contentType string) requestOption {
 	}
 }
 
+func withBetaAssistantV1() requestOption {
+	return func(args *requestOptions) {
+		args.header.Set("OpenAI-Beta", "assistants=v1")
+	}
+}
+
 func (c *Client) newRequest(ctx context.Context, method, url string, setters ...requestOption) (*http.Request, error) {
 	// Default Options
 	args := &requestOptions{
diff --git a/image.go b/image.go
index 4addcdb..4fe8b3a 100644
--- a/image.go
+++ b/image.go
@@ -13,6 +13,9 @@ const (
 	CreateImageSize256x256   = "256x256"
 	CreateImageSize512x512   = "512x512"
 	CreateImageSize1024x1024 = "1024x1024"
+	// dall-e-3 supported only.
+	CreateImageSize1792x1024 = "1792x1024"
+	CreateImageSize1024x1792 = "1024x1792"
 )
 
 const (
@@ -20,11 +23,29 @@ const (
 	CreateImageResponseFormatB64JSON = "b64_json"
 )
 
+const (
+	CreateImageModelDallE2 = "dall-e-2"
+	CreateImageModelDallE3 = "dall-e-3"
+)
+
+const (
+	CreateImageQualityHD       = "hd"
+	CreateImageQualityStandard = "standard"
+)
+
+const (
+	CreateImageStyleVivid   = "vivid"
+	CreateImageStyleNatural = "natural"
+)
+
 // ImageRequest represents the request structure for the image API.
 type ImageRequest struct {
 	Prompt         string `json:"prompt,omitempty"`
+	Model          string `json:"model,omitempty"`
 	N              int    `json:"n,omitempty"`
+	Quality        string `json:"quality,omitempty"`
 	Size           string `json:"size,omitempty"`
+	Style          string `json:"style,omitempty"`
 	ResponseFormat string `json:"response_format,omitempty"`
 	User           string `json:"user,omitempty"`
 }
@@ -39,8 +60,9 @@ type ImageResponse struct {
 
 // ImageResponseDataInner represents a response data structure for image API.
 type ImageResponseDataInner struct {
-	URL     string `json:"url,omitempty"`
-	B64JSON string `json:"b64_json,omitempty"`
+	URL           string `json:"url,omitempty"`
+	B64JSON       string `json:"b64_json,omitempty"`
+	RevisedPrompt string `json:"revised_prompt,omitempty"`
 }
 
 // CreateImage - API call to create an image. This is the main endpoint of the DALL-E API.
diff --git a/thread.go b/thread.go
new file mode 100644
index 0000000..291f3dc
--- /dev/null
+++ b/thread.go
@@ -0,0 +1,107 @@
+package openai
+
+import (
+	"context"
+	"net/http"
+)
+
+const (
+	threadsSuffix = "/threads"
+)
+
+type Thread struct {
+	ID        string         `json:"id"`
+	Object    string         `json:"object"`
+	CreatedAt int64          `json:"created_at"`
+	Metadata  map[string]any `json:"metadata"`
+
+	httpHeader
+}
+
+type ThreadRequest struct {
+	Messages []ThreadMessage `json:"messages,omitempty"`
+	Metadata map[string]any  `json:"metadata,omitempty"`
+}
+
+type ModifyThreadRequest struct {
+	Metadata map[string]any `json:"metadata"`
+}
+
+type ThreadMessageRole string
+
+const (
+	ThreadMessageRoleUser ThreadMessageRole = "user"
+)
+
+type ThreadMessage struct {
+	Role     ThreadMessageRole `json:"role"`
+	Content  string            `json:"content"`
+	FileIDs  []string          `json:"file_ids,omitempty"`
+	Metadata map[string]any    `json:"metadata,omitempty"`
+}
+
+type ThreadDeleteResponse struct {
+	ID      string `json:"id"`
+	Object  string `json:"object"`
+	Deleted bool   `json:"deleted"`
+
+	httpHeader
+}
+
+// CreateThread creates a new thread.
+func (c *Client) CreateThread(ctx context.Context, request ThreadRequest) (response Thread, err error) {
+	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(threadsSuffix), withBody(request),
+		withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &response)
+	return
+}
+
+// RetrieveThread retrieves a thread.
+func (c *Client) RetrieveThread(ctx context.Context, threadID string) (response Thread, err error) {
+	urlSuffix := threadsSuffix + "/" + threadID
+	req, err := c.newRequest(ctx, http.MethodGet, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &response)
+	return
+}
+
+// ModifyThread modifies a thread.
+func (c *Client) ModifyThread(
+	ctx context.Context,
+	threadID string,
+	request ModifyThreadRequest,
+) (response Thread, err error) {
+	urlSuffix := threadsSuffix + "/" + threadID
+	req, err := c.newRequest(ctx, http.MethodPost, c.fullURL(urlSuffix), withBody(request),
+		withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &response)
+	return
+}
+
+// DeleteThread deletes a thread.
+func (c *Client) DeleteThread(
+	ctx context.Context,
+	threadID string,
+) (response ThreadDeleteResponse, err error) {
+	urlSuffix := threadsSuffix + "/" + threadID
+	req, err := c.newRequest(ctx, http.MethodDelete, c.fullURL(urlSuffix),
+		withBetaAssistantV1())
+	if err != nil {
+		return
+	}
+
+	err = c.sendRequest(req, &response)
+	return
+}


Test patch:
diff --git a/api_integration_test.go b/api_integration_test.go
index 6be188b..736040c 100644
--- a/api_integration_test.go
+++ b/api_integration_test.go
@@ -9,6 +9,7 @@ import (
 	"os"
 	"testing"
 
+	"github.com/sashabaranov/go-openai"
 	"github.com/sashabaranov/go-openai/internal/test/checks"
 	"github.com/sashabaranov/go-openai/jsonschema"
 )
@@ -20,7 +21,7 @@ func TestAPI(t *testing.T) {
 	}
 
 	var err error
-	c := NewClient(apiToken)
+	c := openai.NewClient(apiToken)
 	ctx := context.Background()
 	_, err = c.ListEngines(ctx)
 	checks.NoError(t, err, "ListEngines error")
@@ -36,23 +37,23 @@ func TestAPI(t *testing.T) {
 		checks.NoError(t, err, "GetFile error")
 	} // else skip
 
-	embeddingReq := EmbeddingRequest{
+	embeddingReq := openai.EmbeddingRequest{
 		Input: []string{
 			"The food was delicious and the waiter",
 			"Other examples of embedding request",
 		},
-		Model: AdaSearchQuery,
+		Model: openai.AdaSearchQuery,
 	}
 	_, err = c.CreateEmbeddings(ctx, embeddingReq)
 	checks.NoError(t, err, "Embedding error")
 
 	_, err = c.CreateChatCompletion(
 		ctx,
-		ChatCompletionRequest{
-			Model: GPT3Dot5Turbo,
-			Messages: []ChatCompletionMessage{
+		openai.ChatCompletionRequest{
+			Model: openai.GPT3Dot5Turbo,
+			Messages: []openai.ChatCompletionMessage{
 				{
-					Role:    ChatMessageRoleUser,
+					Role:    openai.ChatMessageRoleUser,
 					Content: "Hello!",
 				},
 			},
@@ -63,11 +64,11 @@ func TestAPI(t *testing.T) {
 
 	_, err = c.CreateChatCompletion(
 		ctx,
-		ChatCompletionRequest{
-			Model: GPT3Dot5Turbo,
-			Messages: []ChatCompletionMessage{
+		openai.ChatCompletionRequest{
+			Model: openai.GPT3Dot5Turbo,
+			Messages: []openai.ChatCompletionMessage{
 				{
-					Role:    ChatMessageRoleUser,
+					Role:    openai.ChatMessageRoleUser,
 					Name:    "John_Doe",
 					Content: "Hello!",
 				},
@@ -76,9 +77,9 @@ func TestAPI(t *testing.T) {
 	)
 	checks.NoError(t, err, "CreateChatCompletion (with name) returned error")
 
-	stream, err := c.CreateCompletionStream(ctx, CompletionRequest{
+	stream, err := c.CreateCompletionStream(ctx, openai.CompletionRequest{
 		Prompt:    "Ex falso quodlibet",
-		Model:     GPT3Ada,
+		Model:     openai.GPT3Ada,
 		MaxTokens: 5,
 		Stream:    true,
 	})
@@ -103,15 +104,15 @@ func TestAPI(t *testing.T) {
 
 	_, err = c.CreateChatCompletion(
 		context.Background(),
-		ChatCompletionRequest{
-			Model: GPT3Dot5Turbo,
-			Messages: []ChatCompletionMessage{
+		openai.ChatCompletionRequest{
+			Model: openai.GPT3Dot5Turbo,
+			Messages: []openai.ChatCompletionMessage{
 				{
-					Role:    ChatMessageRoleUser,
+					Role:    openai.ChatMessageRoleUser,
 					Content: "What is the weather like in Boston?",
 				},
 			},
-			Functions: []FunctionDefinition{{
+			Functions: []openai.FunctionDefinition{{
 				Name: "get_current_weather",
 				Parameters: jsonschema.Definition{
 					Type: jsonschema.Object,
@@ -140,12 +141,12 @@ func TestAPIError(t *testing.T) {
 	}
 
 	var err error
-	c := NewClient(apiToken + "_invalid")
+	c := openai.NewClient(apiToken + "_invalid")
 	ctx := context.Background()
 	_, err = c.ListEngines(ctx)
 	checks.HasError(t, err, "ListEngines should fail with an invalid key")
 
-	var apiErr *APIError
+	var apiErr *openai.APIError
 	if !errors.As(err, &apiErr) {
 		t.Fatalf("Error is not an APIError: %+v", err)
 	}
diff --git a/chat_test.go b/chat_test.go
index 5bf1eaf..a8155ed 100644
--- a/chat_test.go
+++ b/chat_test.go
@@ -51,6 +51,20 @@ func TestChatCompletionsWrongModel(t *testing.T) {
 	checks.ErrorIs(t, err, openai.ErrChatCompletionInvalidModel, msg)
 }
 
+func TestChatRequestOmitEmpty(t *testing.T) {
+	data, err := json.Marshal(openai.ChatCompletionRequest{
+		// We set model b/c it's required, so omitempty doesn't make sense
+		Model: "gpt-4",
+	})
+	checks.NoError(t, err)
+
+	// messages is also required so isn't omitted
+	const expected = `{"model":"gpt-4","messages":null}`
+	if string(data) != expected {
+		t.Errorf("expected JSON with all empty fields to be %v but was %v", expected, string(data))
+	}
+}
+
 func TestChatCompletionsWithStream(t *testing.T) {
 	config := openai.DefaultConfig("whatever")
 	config.BaseURL = "http://localhost/v1"
diff --git a/client_test.go b/client_test.go
index bff2597..b2f28f9 100644
--- a/client_test.go
+++ b/client_test.go
@@ -301,6 +301,18 @@ func TestClientReturnsRequestBuilderErrors(t *testing.T) {
 		{"DeleteAssistantFile", func() (any, error) {
 			return nil, client.DeleteAssistantFile(ctx, "", "")
 		}},
+		{"CreateThread", func() (any, error) {
+			return client.CreateThread(ctx, ThreadRequest{})
+		}},
+		{"RetrieveThread", func() (any, error) {
+			return client.RetrieveThread(ctx, "")
+		}},
+		{"ModifyThread", func() (any, error) {
+			return client.ModifyThread(ctx, "", ModifyThreadRequest{})
+		}},
+		{"DeleteThread", func() (any, error) {
+			return client.DeleteThread(ctx, "")
+		}},
 	}
 
 	for _, testCase := range testCases {
diff --git a/fine_tuning_job_test.go b/fine_tuning_job_test.go
index c892ef7..d2fbcd4 100644
--- a/fine_tuning_job_test.go
+++ b/fine_tuning_job_test.go
@@ -42,7 +42,7 @@ func TestFineTuningJob(t *testing.T) {
 	)
 
 	server.RegisterHandler(
-		"/fine_tuning/jobs/"+testFineTuninigJobID+"/cancel",
+		"/v1/fine_tuning/jobs/"+testFineTuninigJobID+"/cancel",
 		func(w http.ResponseWriter, _ *http.Request) {
 			resBytes, _ := json.Marshal(openai.FineTuningJob{})
 			fmt.Fprintln(w, string(resBytes))
diff --git a/image_api_test.go b/image_api_test.go
index 422f831..2eb46f2 100644
--- a/image_api_test.go
+++ b/image_api_test.go
@@ -19,7 +19,14 @@ func TestImages(t *testing.T) {
 	defer teardown()
 	server.RegisterHandler("/v1/images/generations", handleImageEndpoint)
 	_, err := client.CreateImage(context.Background(), openai.ImageRequest{
-		Prompt: "Lorem ipsum",
+		Prompt:         "Lorem ipsum",
+		Model:          openai.CreateImageModelDallE3,
+		N:              1,
+		Quality:        openai.CreateImageQualityHD,
+		Size:           openai.CreateImageSize1024x1024,
+		Style:          openai.CreateImageStyleVivid,
+		ResponseFormat: openai.CreateImageResponseFormatURL,
+		User:           "user",
 	})
 	checks.NoError(t, err, "CreateImage error")
 }
diff --git a/internal/test/server.go b/internal/test/server.go
index 3813ff8..127d4c1 100644
--- a/internal/test/server.go
+++ b/internal/test/server.go
@@ -5,6 +5,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"regexp"
+	"strings"
 )
 
 const testAPI = "this-is-my-secure-token-do-not-steal!!"
@@ -23,13 +24,16 @@ func NewTestServer() *ServerTest {
 }
 
 func (ts *ServerTest) RegisterHandler(path string, handler handler) {
+	// to make the registered paths friendlier to a regex match in the route handler
+	// in OpenAITestServer
+	path = strings.ReplaceAll(path, "*", ".*")
 	ts.handlers[path] = handler
 }
 
 // OpenAITestServer Creates a mocked OpenAI server which can pretend to handle requests during testing.
 func (ts *ServerTest) OpenAITestServer() *httptest.Server {
 	return httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		log.Printf("received request at path %q\n", r.URL.Path)
+		log.Printf("received a %s request at path %q\n", r.Method, r.URL.Path)
 
 		// check auth
 		if r.Header.Get("Authorization") != "Bearer "+GetTestToken() && r.Header.Get("api-key") != GetTestToken() {
@@ -38,8 +42,10 @@ func (ts *ServerTest) OpenAITestServer() *httptest.Server {
 		}
 
 		// Handle /path/* routes.
+		// Note: the * is converted to a .* in register handler for proper regex handling
 		for route, handler := range ts.handlers {
-			pattern, _ := regexp.Compile(route)
+			// Adding ^ and $ to make path matching deterministic since go map iteration isn't ordered
+			pattern, _ := regexp.Compile("^" + route + "$")
 			if pattern.MatchString(r.URL.Path) {
 				handler(w, r)
 				return
diff --git a/thread_test.go b/thread_test.go
new file mode 100644
index 0000000..227ab63
--- /dev/null
+++ b/thread_test.go
@@ -0,0 +1,95 @@
+package openai_test
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"testing"
+
+	openai "github.com/sashabaranov/go-openai"
+	"github.com/sashabaranov/go-openai/internal/test/checks"
+)
+
+// TestThread Tests the thread endpoint of the API using the mocked server.
+func TestThread(t *testing.T) {
+	threadID := "thread_abc123"
+	client, server, teardown := setupOpenAITestServer()
+	defer teardown()
+
+	server.RegisterHandler(
+		"/v1/threads/"+threadID,
+		func(w http.ResponseWriter, r *http.Request) {
+			switch r.Method {
+			case http.MethodGet:
+				resBytes, _ := json.Marshal(openai.Thread{
+					ID:        threadID,
+					Object:    "thread",
+					CreatedAt: 1234567890,
+				})
+				fmt.Fprintln(w, string(resBytes))
+			case http.MethodPost:
+				var request openai.ThreadRequest
+				err := json.NewDecoder(r.Body).Decode(&request)
+				checks.NoError(t, err, "Decode error")
+
+				resBytes, _ := json.Marshal(openai.Thread{
+					ID:        threadID,
+					Object:    "thread",
+					CreatedAt: 1234567890,
+				})
+				fmt.Fprintln(w, string(resBytes))
+			case http.MethodDelete:
+				fmt.Fprintln(w, `{
+					"id": "thread_abc123",
+					"object": "thread.deleted",
+					"deleted": true
+					}`)
+			}
+		},
+	)
+
+	server.RegisterHandler(
+		"/v1/threads",
+		func(w http.ResponseWriter, r *http.Request) {
+			if r.Method == http.MethodPost {
+				var request openai.ModifyThreadRequest
+				err := json.NewDecoder(r.Body).Decode(&request)
+				checks.NoError(t, err, "Decode error")
+
+				resBytes, _ := json.Marshal(openai.Thread{
+					ID:        threadID,
+					Object:    "thread",
+					CreatedAt: 1234567890,
+					Metadata:  request.Metadata,
+				})
+				fmt.Fprintln(w, string(resBytes))
+			}
+		},
+	)
+
+	ctx := context.Background()
+
+	_, err := client.CreateThread(ctx, openai.ThreadRequest{
+		Messages: []openai.ThreadMessage{
+			{
+				Role:    openai.ThreadMessageRoleUser,
+				Content: "Hello, World!",
+			},
+		},
+	})
+	checks.NoError(t, err, "CreateThread error")
+
+	_, err = client.RetrieveThread(ctx, threadID)
+	checks.NoError(t, err, "RetrieveThread error")
+
+	_, err = client.ModifyThread(ctx, threadID, openai.ModifyThreadRequest{
+		Metadata: map[string]interface{}{
+			"key": "value",
+		},
+	})
+	checks.NoError(t, err, "ModifyThread error")
+
+	_, err = client.DeleteThread(ctx, threadID)
+	checks.NoError(t, err, "DeleteThread error")
+}