Repository: sashabaranov/go-openai
Pull request #558 (2023-11-09 07:07:17)

fix: add missing fields in tool_calls
**Describe the change**
There are [missing fields](https://platform.openai.com/docs/api-reference/chat/streaming) in `tool_calls` object. Following the documentation the [chat completion chunk object](https://platform.openai.com/docs/api-reference/chat/streaming) has the `index` field also.

**Describe your solution**
Just added missing fields


Issue: #555 


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sashabaranov/go-openai/pull/558?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#558](https://app.codecov.io/gh/sashabaranov/go-openai/pull/558?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (7eb4e62) into [master](https://app.codecov.io/gh/sashabaranov/go-openai/commit/08c167fecf6953619d1905ab2959ed341bfb063d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (08c167f) will **not change** coverage.
> The diff coverage is `n/a`.

```diff
@@           Coverage Diff           @@
##           master     #558   +/-   ##
=======================================
  Coverage   97.88%   97.88%           
=======================================
  Files          20       20           
  Lines         991      991           
=======================================
  Hits          970      970           
  Misses         15       15           
  Partials        6        6           
```


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



Patch:
diff --git a/chat.go b/chat.go
index 609e0c3..ebdc0e2 100644
--- a/chat.go
+++ b/chat.go
@@ -62,11 +62,19 @@ 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 {
+	// Index is not nil only in chat completion chunk object
+	Index    *int         `json:"index,omitempty"`
 	ID       string       `json:"id"`
+	Type     ToolType     `json:"type"`
 	Function FunctionCall `json:"function"`
 }
 
@@ -84,7 +92,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 +120,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 +134,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 +190,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/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/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/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")
+}