Repository: sashabaranov/go-openai
Pull request #293 (2023-05-01 07:50:46)

maintain underlying error structs to allow for type conversion
Resolves https://github.com/sashabaranov/go-openai/issues/292, https://github.com/sashabaranov/go-openai/issues/288


Maintains underlying error structs to allow consumers to utilize type conversion and create a defensive structure as defined by the Open AI error handling documentation.

It also slightly modifies the Error() functions to that they are responsible for converting the struct into an informational string versus just a fragment of its parent

Modifies only a single test that was missing a colon when all of the other tests have colons (including the RequestError Error function that was missing the colon that the other Error function included

codecov[bot]: ## [Codecov](https://codecov.io/gh/sashabaranov/go-openai/pull/293?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#293](https://codecov.io/gh/sashabaranov/go-openai/pull/293?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (eb4cd51) into [master](https://codecov.io/gh/sashabaranov/go-openai/commit/af9ff510779fb9e64110f1034df742bde751ba1b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (af9ff51) will **not change** coverage.
> The diff coverage is `100.00%`.

```diff
@@           Coverage Diff           @@
##           master     #293   +/-   ##
=======================================
  Coverage   92.43%   92.43%           
=======================================
  Files          22       22           
  Lines         661      661           
=======================================
  Hits          611      611           
  Misses         36       36           
  Partials       14       14           
```


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


sashabaranov: @qhenkart thank you for the PR! It's a bit hard for me to follow all the error cases here — could you please either add a test or code sample clearly demonstrating the benefits of this approach?
qhenkart: @sashabaranov I have added a test to ensure type conversion as well as updated the README as requested in the linked issue to provide guidance on how to handle errors utilizing proper error package syntax.

I have another proposal that would further improve the error handling experience for consumers of this library. But I would like to keep this PR as the simple fix that it is so I will open up a separate issue so we can discuss

Patch:
diff --git a/README.md b/README.md
index 7526ea3..f7e6990 100644
--- a/README.md
+++ b/README.md
@@ -10,13 +10,13 @@ This library provides Go clients for [OpenAI API](https://platform.openai.com/).
 * DALL·E 2
 * Whisper
 
-Installation:
+### Installation:
 ```
 go get github.com/sashabaranov/go-openai
 ```
 
 
-ChatGPT example usage:
+### ChatGPT example usage:
 
 ```go
 package main
@@ -52,9 +52,7 @@ func main() {
 
 ```
 
-
-
-Other examples:
+### Other examples:
 
 <details>
 <summary>ChatGPT streaming completion</summary>
@@ -462,3 +460,29 @@ func main() {
 }
 ```
 </details>
+
+<details>
+<summary>Error handling</summary>
+
+Open-AI maintains clear documentation on how to [handle API errors](https://platform.openai.com/docs/guides/error-codes/api-errors)
+
+example:
+```
+e := &openai.APIError{}
+if errors.As(err, &e) {
+  switch e.HTTPStatusCode {
+    case 401:
+      // invalid auth or key (do not retry)
+    case 429:
+      // rate limiting or engine overload (wait and retry) 
+    case 500:
+      // openai server error (retry)
+    default:
+      // unhandled
+  }
+}
+
+```
+</details>
+
+
diff --git a/chat_stream.go b/chat_stream.go
index b5257cc..9ed0bc7 100644
--- a/chat_stream.go
+++ b/chat_stream.go
@@ -7,7 +7,8 @@ import (
 )
 
 type ChatCompletionStreamChoiceDelta struct {
-	Content string `json:"content"`
+	Content string `json:"content,omitempty"`
+	Role    string `json:"role,omitempty"`
 }
 
 type ChatCompletionStreamChoice struct {
diff --git a/client.go b/client.go
index 500b3d5..0f8aa41 100644
--- a/client.go
+++ b/client.go
@@ -103,6 +103,11 @@ func (c *Client) fullURL(suffix string) string {
 	if c.config.APIType == APITypeAzure || c.config.APIType == APITypeAzureAD {
 		baseURL := c.config.BaseURL
 		baseURL = strings.TrimRight(baseURL, "/")
+		// if suffix is /models change to {endpoint}/openai/models?api-version=2022-12-01
+		// https://learn.microsoft.com/en-us/rest/api/cognitiveservices/azureopenaistable/models/list?tabs=HTTP
+		if strings.Contains(suffix, "/models") {
+			return fmt.Sprintf("%s/%s%s?api-version=%s", baseURL, azureAPIPrefix, suffix, c.config.APIVersion)
+		}
 		return fmt.Sprintf("%s/%s/%s/%s%s?api-version=%s",
 			baseURL, azureAPIPrefix, azureDeploymentsPrefix, c.config.Engine, suffix, c.config.APIVersion)
 	}
@@ -144,15 +149,16 @@ func (c *Client) handleErrorResp(resp *http.Response) error {
 	var errRes ErrorResponse
 	err := json.NewDecoder(resp.Body).Decode(&errRes)
 	if err != nil || errRes.Error == nil {
-		reqErr := RequestError{
+		reqErr := &RequestError{
 			HTTPStatusCode: resp.StatusCode,
 			Err:            err,
 		}
 		if errRes.Error != nil {
 			reqErr.Err = errRes.Error
 		}
-		return fmt.Errorf("error, %w", &reqErr)
+		return reqErr
 	}
+
 	errRes.Error.HTTPStatusCode = resp.StatusCode
-	return fmt.Errorf("error, status code: %d, message: %w", resp.StatusCode, errRes.Error)
+	return errRes.Error
 }
diff --git a/error.go b/error.go
index 86b75f4..6354f43 100644
--- a/error.go
+++ b/error.go
@@ -25,6 +25,10 @@ type ErrorResponse struct {
 }
 
 func (e *APIError) Error() string {
+	if e.HTTPStatusCode > 0 {
+		return fmt.Sprintf("error, status code: %d, message: %s", e.HTTPStatusCode, e.Message)
+	}
+
 	return e.Message
 }
 
@@ -70,7 +74,7 @@ func (e *APIError) UnmarshalJSON(data []byte) (err error) {
 }
 
 func (e *RequestError) Error() string {
-	return fmt.Sprintf("status code %d, message: %s", e.HTTPStatusCode, e.Err)
+	return fmt.Sprintf("error, status code: %d, message: %s", e.HTTPStatusCode, e.Err)
 }
 
 func (e *RequestError) Unwrap() error {


Test patch:
diff --git a/api_test.go b/api_test.go
index ecba256..78fd5cc 100644
--- a/api_test.go
+++ b/api_test.go
@@ -5,6 +5,8 @@ import (
 	"encoding/json"
 	"errors"
 	"io"
+	"net/http"
+	"net/http/httptest"
 	"os"
 	"testing"
 
@@ -226,8 +228,13 @@ func TestAPIErrorUnmarshalJSONInvalidMessage(t *testing.T) {
 func TestRequestError(t *testing.T) {
 	var err error
 
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusTeapot)
+	}))
+	defer ts.Close()
+
 	config := DefaultConfig("dummy")
-	config.BaseURL = "https://httpbin.org/status/418?"
+	config.BaseURL = ts.URL
 	c := NewClientWithConfig(config)
 	ctx := context.Background()
 	_, err = c.ListEngines(ctx)
diff --git a/client_test.go b/client_test.go
index 7ef6284..e30fa39 100644
--- a/client_test.go
+++ b/client_test.go
@@ -2,6 +2,7 @@ package openai //nolint:testpackage // testing private field
 
 import (
 	"bytes"
+	"errors"
 	"fmt"
 	"io"
 	"net/http"
@@ -106,7 +107,7 @@ func TestHandleErrorResp(t *testing.T) {
 					}
 				}`,
 			)),
-			expected: "error, status code 401, message: Access denied due to Virtual Network/Firewall rules.",
+			expected: "error, status code: 401, message: Access denied due to Virtual Network/Firewall rules.",
 		},
 		{
 			name:     "503 Model Overloaded",
@@ -135,6 +136,12 @@ func TestHandleErrorResp(t *testing.T) {
 				t.Errorf("Unexpected error: %v , expected: %s", err, tc.expected)
 				t.Fail()
 			}
+
+			e := &APIError{}
+			if !errors.As(err, &e) {
+				t.Errorf("(%s) Expected error to be of type APIError", tc.name)
+				t.Fail()
+			}
 		})
 	}
 }
diff --git a/internal/test/server.go b/internal/test/server.go
index 0c6f67d..79d55c4 100644
--- a/internal/test/server.go
+++ b/internal/test/server.go
@@ -31,7 +31,7 @@ func (ts *ServerTest) OpenAITestServer() *httptest.Server {
 		log.Printf("received request at path %q\n", r.URL.Path)
 
 		// check auth
-		if r.Header.Get("Authorization") != "Bearer "+GetTestToken() {
+		if r.Header.Get("Authorization") != "Bearer "+GetTestToken() && r.Header.Get("api-key") != GetTestToken() {
 			w.WriteHeader(http.StatusUnauthorized)
 			return
 		}
diff --git a/models_test.go b/models_test.go
index dad59be..70d6d75 100644
--- a/models_test.go
+++ b/models_test.go
@@ -31,6 +31,24 @@ func TestListModels(t *testing.T) {
 	checks.NoError(t, err, "ListModels error")
 }
 
+func TestAzureListModels(t *testing.T) {
+	server := test.NewTestServer()
+	server.RegisterHandler("/openai/models", handleModelsEndpoint)
+	// create the test server
+	var err error
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultAzureConfig(test.GetTestToken(), "https://dummylab.openai.azure.com/", "dummyengine")
+	config.BaseURL = ts.URL
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	_, err = client.ListModels(ctx)
+	checks.NoError(t, err, "ListModels error")
+}
+
 // handleModelsEndpoint Handles the models endpoint by the test server.
 func handleModelsEndpoint(w http.ResponseWriter, _ *http.Request) {
 	resBytes, _ := json.Marshal(ModelsList{})