Repository: sashabaranov/go-openai
Pull request #348 (2023-06-09 00:19:20)

Support Retrieve file content API (#347)
This is a pull request for https://github.com/sashabaranov/go-openai/issues/347, I implemented the Retrieve file content API.
The return value of the `GetFileContent` function is `io.ReadCloser`.  Because library users flexibility in handling the response.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/sashabaranov/go-openai/pull/348?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#348](https://app.codecov.io/gh/sashabaranov/go-openai/pull/348?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (489d142) into [master](https://app.codecov.io/gh/sashabaranov/go-openai/commit/6830e0040677bde88d1ed40992667cfabb017910?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (6830e00) will **increase** coverage by `1.12%`.
> The diff coverage is `91.89%`.

```diff
@@            Coverage Diff             @@
##           master     #348      +/-   ##
==========================================
+ Coverage   93.65%   94.77%   +1.12%     
==========================================
  Files          17       17              
  Lines         646      670      +24     
==========================================
+ Hits          605      635      +30     
+ Misses         30       24       -6     
  Partials       11       11              
```


| [Impacted Files](https://app.codecov.io/gh/sashabaranov/go-openai/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [files.go](https://app.codecov.io/gh/sashabaranov/go-openai/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-ZmlsZXMuZ28=) | `91.30% <83.33%> (+8.95%)` | :arrow_up: |
| [chat\_stream.go](https://app.codecov.io/gh/sashabaranov/go-openai/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-Y2hhdF9zdHJlYW0uZ28=) | `89.28% <100.00%> (ø)` | |
| [client.go](https://app.codecov.io/gh/sashabaranov/go-openai/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-Y2xpZW50Lmdv) | `94.59% <100.00%> (+2.63%)` | :arrow_up: |
| [stream.go](https://app.codecov.io/gh/sashabaranov/go-openai/pull/348?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-c3RyZWFtLmdv) | `90.62% <100.00%> (ø)` | |

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


vvatanabe: @sashabaranov Please check it. Thank you always for reviewing.

Patch:
diff --git a/chat_stream.go b/chat_stream.go
index 9378c71..625d436 100644
--- a/chat_stream.go
+++ b/chat_stream.go
@@ -3,7 +3,6 @@ package openai
 import (
 	"bufio"
 	"context"
-	"net/http"
 
 	utils "github.com/sashabaranov/go-openai/internal"
 )
@@ -57,7 +56,7 @@ func (c *Client) CreateChatCompletionStream(
 	if err != nil {
 		return
 	}
-	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
+	if isFailureStatusCode(resp) {
 		return nil, c.handleErrorResp(resp)
 	}
 
diff --git a/client.go b/client.go
index 2486e36..f38c1df 100644
--- a/client.go
+++ b/client.go
@@ -47,13 +47,6 @@ func NewOrgClient(authToken, org string) *Client {
 
 func (c *Client) sendRequest(req *http.Request, v any) error {
 	req.Header.Set("Accept", "application/json; charset=utf-8")
-	// Azure API Key authentication
-	if c.config.APIType == APITypeAzure {
-		req.Header.Set(AzureAPIKeyHeader, c.config.authToken)
-	} else {
-		// OpenAI or Azure AD authentication
-		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.authToken))
-	}
 
 	// Check whether Content-Type is already set, Upload Files API requires
 	// Content-Type == multipart/form-data
@@ -62,9 +55,7 @@ func (c *Client) sendRequest(req *http.Request, v any) error {
 		req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	}
 
-	if len(c.config.OrgID) > 0 {
-		req.Header.Set("OpenAI-Organization", c.config.OrgID)
-	}
+	c.setCommonHeaders(req)
 
 	res, err := c.config.HTTPClient.Do(req)
 	if err != nil {
@@ -73,13 +64,31 @@ func (c *Client) sendRequest(req *http.Request, v any) error {
 
 	defer res.Body.Close()
 
-	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+	if isFailureStatusCode(res) {
 		return c.handleErrorResp(res)
 	}
 
 	return decodeResponse(res.Body, v)
 }
 
+func (c *Client) setCommonHeaders(req *http.Request) {
+	// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference#authentication
+	// Azure API Key authentication
+	if c.config.APIType == APITypeAzure {
+		req.Header.Set(AzureAPIKeyHeader, c.config.authToken)
+	} else {
+		// OpenAI or Azure AD authentication
+		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.authToken))
+	}
+	if c.config.OrgID != "" {
+		req.Header.Set("OpenAI-Organization", c.config.OrgID)
+	}
+}
+
+func isFailureStatusCode(resp *http.Response) bool {
+	return resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest
+}
+
 func decodeResponse(body io.Reader, v any) error {
 	if v == nil {
 		return nil
@@ -145,17 +154,7 @@ func (c *Client) newStreamRequest(
 	req.Header.Set("Cache-Control", "no-cache")
 	req.Header.Set("Connection", "keep-alive")
 
-	// https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference#authentication
-	// Azure API Key authentication
-	if c.config.APIType == APITypeAzure {
-		req.Header.Set(AzureAPIKeyHeader, c.config.authToken)
-	} else {
-		// OpenAI or Azure AD authentication
-		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.config.authToken))
-	}
-	if c.config.OrgID != "" {
-		req.Header.Set("OpenAI-Organization", c.config.OrgID)
-	}
+	c.setCommonHeaders(req)
 	return req, nil
 }
 
diff --git a/files.go b/files.go
index 36c0243..fb9937b 100644
--- a/files.go
+++ b/files.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"context"
 	"fmt"
+	"io"
 	"net/http"
 	"os"
 )
@@ -103,3 +104,26 @@ func (c *Client) GetFile(ctx context.Context, fileID string) (file File, err err
 	err = c.sendRequest(req, &file)
 	return
 }
+
+func (c *Client) GetFileContent(ctx context.Context, fileID string) (content io.ReadCloser, err error) {
+	urlSuffix := fmt.Sprintf("/files/%s/content", fileID)
+	req, err := c.requestBuilder.Build(ctx, http.MethodGet, c.fullURL(urlSuffix), nil)
+	if err != nil {
+		return
+	}
+
+	c.setCommonHeaders(req)
+
+	res, err := c.config.HTTPClient.Do(req)
+	if err != nil {
+		return
+	}
+
+	if isFailureStatusCode(res) {
+		err = c.handleErrorResp(res)
+		return
+	}
+
+	content = res.Body
+	return
+}
diff --git a/stream.go b/stream.go
index d4e3523..94cc0a0 100644
--- a/stream.go
+++ b/stream.go
@@ -4,7 +4,6 @@ import (
 	"bufio"
 	"context"
 	"errors"
-	"net/http"
 
 	utils "github.com/sashabaranov/go-openai/internal"
 )
@@ -46,7 +45,7 @@ func (c *Client) CreateCompletionStream(
 	if err != nil {
 		return
 	}
-	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
+	if isFailureStatusCode(resp) {
 		return nil, c.handleErrorResp(resp)
 	}
 


Test patch:
diff --git a/client_test.go b/client_test.go
index c96ceb7..70ac813 100644
--- a/client_test.go
+++ b/client_test.go
@@ -233,6 +233,9 @@ func TestClientReturnsRequestBuilderErrors(t *testing.T) {
 		{"GetFile", func() (any, error) {
 			return client.GetFile(ctx, "")
 		}},
+		{"GetFileContent", func() (any, error) {
+			return client.GetFileContent(ctx, "")
+		}},
 		{"ListFiles", func() (any, error) {
 			return client.ListFiles(ctx)
 		}},
diff --git a/files_test.go b/files_test.go
index ffdcfa7..8e89349 100644
--- a/files_test.go
+++ b/files_test.go
@@ -7,6 +7,7 @@ import (
 
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -141,3 +142,168 @@ func TestFileUploadWithNonExistentPath(t *testing.T) {
 	_, err := client.CreateFile(ctx, req)
 	checks.ErrorIs(t, err, os.ErrNotExist, "CreateFile should return error if file does not exist")
 }
+
+func TestDeleteFile(t *testing.T) {
+	server := test.NewTestServer()
+	server.RegisterHandler("/v1/files/deadbeef", func(w http.ResponseWriter, r *http.Request) {
+
+	})
+	// create the test server
+	var err error
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = ts.URL + "/v1"
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	err = client.DeleteFile(ctx, "deadbeef")
+	checks.NoError(t, err, "DeleteFile error")
+}
+
+func TestListFile(t *testing.T) {
+	server := test.NewTestServer()
+	server.RegisterHandler("/v1/files", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, "{}")
+	})
+	// create the test server
+	var err error
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = ts.URL + "/v1"
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	_, err = client.ListFiles(ctx)
+	checks.NoError(t, err, "ListFiles error")
+}
+
+func TestGetFile(t *testing.T) {
+	server := test.NewTestServer()
+	server.RegisterHandler("/v1/files/deadbeef", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, "{}")
+	})
+	// create the test server
+	var err error
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = ts.URL + "/v1"
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	_, err = client.GetFile(ctx, "deadbeef")
+	checks.NoError(t, err, "GetFile error")
+}
+
+func TestGetFileContent(t *testing.T) {
+	wantRespJsonl := `{"prompt": "foo", "completion": "foo"}
+{"prompt": "bar", "completion": "bar"}
+{"prompt": "baz", "completion": "baz"}
+`
+	server := test.NewTestServer()
+	server.RegisterHandler("/v1/files/deadbeef/content", func(w http.ResponseWriter, r *http.Request) {
+		// edits only accepts GET requests
+		if r.Method != http.MethodGet {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		}
+		fmt.Fprint(w, wantRespJsonl)
+	})
+	// create the test server
+	var err error
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = ts.URL + "/v1"
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	content, err := client.GetFileContent(ctx, "deadbeef")
+	checks.NoError(t, err, "GetFileContent error")
+	defer content.Close()
+
+	actual, _ := io.ReadAll(content)
+	if string(actual) != wantRespJsonl {
+		t.Errorf("Expected %s, got %s", wantRespJsonl, string(actual))
+	}
+}
+
+func TestGetFileContentReturnError(t *testing.T) {
+	wantMessage := "To help mitigate abuse, downloading of fine-tune training files is disabled for free accounts."
+	wantType := "invalid_request_error"
+	wantErrorResp := `{
+  "error": {
+    "message": "` + wantMessage + `",
+    "type": "` + wantType + `",
+    "param": null,
+    "code": null
+  }
+}`
+	server := test.NewTestServer()
+	server.RegisterHandler("/v1/files/deadbeef/content", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusBadRequest)
+		fmt.Fprint(w, wantErrorResp)
+	})
+	// create the test server
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = ts.URL + "/v1"
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	_, err := client.GetFileContent(ctx, "deadbeef")
+	if err == nil {
+		t.Fatal("Did not return error")
+	}
+
+	apiErr := &APIError{}
+	if !errors.As(err, &apiErr) {
+		t.Fatalf("Did not return APIError: %+v\n", apiErr)
+	}
+	if apiErr.Message != wantMessage {
+		t.Fatalf("Expected %s Message, got = %s\n", wantMessage, apiErr.Message)
+		return
+	}
+	if apiErr.Type != wantType {
+		t.Fatalf("Expected %s Type, got = %s\n", wantType, apiErr.Type)
+		return
+	}
+}
+
+func TestGetFileContentReturnTimeoutError(t *testing.T) {
+	server := test.NewTestServer()
+	server.RegisterHandler("/v1/files/deadbeef/content", func(w http.ResponseWriter, r *http.Request) {
+		time.Sleep(10 * time.Nanosecond)
+	})
+	// create the test server
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = ts.URL + "/v1"
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+	ctx, cancel := context.WithTimeout(ctx, time.Nanosecond)
+	defer cancel()
+
+	_, err := client.GetFileContent(ctx, "deadbeef")
+	if err == nil {
+		t.Fatal("Did not return error")
+	}
+	if !os.IsTimeout(err) {
+		t.Fatal("Did not return timeout error")
+	}
+}