Repository: sashabaranov/go-openai (go)

Issue:
About Error handling
Hi, when I am using Azure openai chatgpt, I refer to the example of Error handling and cannot catch the error code 429. Every time the 429 code appears, the program will run into the default code block. I am not sure whether it is a problem with my operation or The content returned by azure openai is different from that of openai.
go-openai failed to serialize error messages correctly.THANKS!

```GOLANG
func OpenaiErrorAndCode(openaierr error) (code int, err error) {
	var openaiError = &openai.APIError{}
	if errors.As(openaierr, &openaiError) {
		switch openaiError.HTTPStatusCode {
		case 400:
			return 400, errors.New("xxxxxxx")
		case 401:
			return 401, errors.New("xxxxxxx")
		case 429:
			return 429, errors.New("xxxxxxx")
		case 500:
			return 500, errors.New("xxxxxxx")
		default:
				return 500, errors.New("unknown：" + openaierr.Error())
		}
	}
	return 500, openaierr
}
```
```GOLANG
stream, err := client.CreateChatCompletionStream(ctx, req)
if err != nil {
	fmt.Printf("ChatCompletionStream error: %v\n", err)
	_,openaierr := appError.OpenaiErrorAndCode(err)
	fmt.Printf(openaierr.Error())
}
```

The json returned by azure openai is as follows：
```json
{
    "error": {
        "code": "429",
        "message": "Requests to the Creates a completion for the chat message Operation under Azure OpenAI API version 2023-03-15-preview have exceeded token rate limit of your current OpenAI S0 pricing tier. Please retry after 20 seconds. Please go here: https://aka.ms/oai/quotaincrease if you would like to further increase the default rate limit."
    }
}
```

Pull request description:
fix json marshaling error response of azure openai (#343)
Fix #343

Azure OpenAI error response seems to be `type` field missing sometimes. Error (`unexpected end of JSON input`) when parsing `type` in `APIError.UnmarshalJSON` function.

Example error response:
```
# Azure OpenAI
{
   "error":{
      "code":"429",
      "message":"Requests to the Creates a completion for the chat message Operation under Azure OpenAI API version 2023-03-15-preview have exceeded token rate limit of your current OpenAI S0 pricing tier. Please retry after 17 seconds. Please go here: https://aka.ms/oai/quotaincrease if you would like to further increase the default rate limit."
   }
}
```

**Note:**
I also considered changing the `type` field to a pointer, but decided against it because that would be a breaking changes.

Patch:
diff --git a/error.go b/error.go
index 6354f43..b789ed7 100644
--- a/error.go
+++ b/error.go
@@ -44,9 +44,13 @@ func (e *APIError) UnmarshalJSON(data []byte) (err error) {
 		return
 	}
 
-	err = json.Unmarshal(rawMap["type"], &e.Type)
-	if err != nil {
-		return
+	// optional fields for azure openai
+	// refs: https://github.com/sashabaranov/go-openai/issues/343
+	if _, ok := rawMap["type"]; ok {
+		err = json.Unmarshal(rawMap["type"], &e.Type)
+		if err != nil {
+			return
+		}
 	}
 
 	// optional fields
diff --git a/stream_reader.go b/stream_reader.go
index a9940b0..3416198 100644
--- a/stream_reader.go
+++ b/stream_reader.go
@@ -30,43 +30,52 @@ func (stream *streamReader[T]) Recv() (response T, err error) {
 		return
 	}
 
+	response, err = stream.processLines()
+	return
+}
+
+func (stream *streamReader[T]) processLines() (T, error) {
 	var emptyMessagesCount uint
 
-waitForData:
-	line, err := stream.reader.ReadBytes('\n')
-	if err != nil {
-		respErr := stream.unmarshalError()
-		if respErr != nil {
-			err = fmt.Errorf("error, %w", respErr.Error)
+	for {
+		rawLine, readErr := stream.reader.ReadBytes('\n')
+		if readErr != nil {
+			respErr := stream.unmarshalError()
+			if respErr != nil {
+				return *new(T), fmt.Errorf("error, %w", respErr.Error)
+			}
+			return *new(T), readErr
 		}
-		return
-	}
 
-	var headerData = []byte("data: ")
-	line = bytes.TrimSpace(line)
-	if !bytes.HasPrefix(line, headerData) {
-		if writeErr := stream.errAccumulator.Write(line); writeErr != nil {
-			err = writeErr
-			return
+		var headerData = []byte("data: ")
+		noSpaceLine := bytes.TrimSpace(rawLine)
+		if !bytes.HasPrefix(noSpaceLine, headerData) {
+			writeErr := stream.errAccumulator.Write(noSpaceLine)
+			if writeErr != nil {
+				return *new(T), writeErr
+			}
+			emptyMessagesCount++
+			if emptyMessagesCount > stream.emptyMessagesLimit {
+				return *new(T), ErrTooManyEmptyStreamMessages
+			}
+
+			continue
 		}
-		emptyMessagesCount++
-		if emptyMessagesCount > stream.emptyMessagesLimit {
-			err = ErrTooManyEmptyStreamMessages
-			return
+
+		noPrefixLine := bytes.TrimPrefix(noSpaceLine, headerData)
+		if string(noPrefixLine) == "[DONE]" {
+			stream.isFinished = true
+			return *new(T), io.EOF
 		}
 
-		goto waitForData
-	}
+		var response T
+		unmarshalErr := stream.unmarshaler.Unmarshal(noPrefixLine, &response)
+		if unmarshalErr != nil {
+			return *new(T), unmarshalErr
+		}
 
-	line = bytes.TrimPrefix(line, headerData)
-	if string(line) == "[DONE]" {
-		stream.isFinished = true
-		err = io.EOF
-		return
+		return response, nil
 	}
-
-	err = stream.unmarshaler.Unmarshal(line, &response)
-	return
 }
 
 func (stream *streamReader[T]) unmarshalError() (errResp *ErrorResponse) {


Test patch:
diff --git a/chat_stream_test.go b/chat_stream_test.go
index 77d373c..19c2e3c 100644
--- a/chat_stream_test.go
+++ b/chat_stream_test.go
@@ -255,6 +255,67 @@ func TestCreateChatCompletionStreamRateLimitError(t *testing.T) {
 	t.Logf("%+v\n", apiErr)
 }
 
+func TestAzureCreateChatCompletionStreamRateLimitError(t *testing.T) {
+	wantCode := "429"
+	wantMessage := "Requests to the Creates a completion for the chat message Operation under Azure OpenAI API " +
+		"version 2023-03-15-preview have exceeded token rate limit of your current OpenAI S0 pricing tier. " +
+		"Please retry after 20 seconds. " +
+		"Please go here: https://aka.ms/oai/quotaincrease if you would like to further increase the default rate limit."
+
+	server := test.NewTestServer()
+	server.RegisterHandler("/openai/deployments/gpt-35-turbo/chat/completions",
+		func(w http.ResponseWriter, r *http.Request) {
+			w.Header().Set("Content-Type", "application/json")
+			w.WriteHeader(http.StatusTooManyRequests)
+			// Send test responses
+			dataBytes := []byte(`{"error": { "code": "` + wantCode + `", "message": "` + wantMessage + `"}}`)
+			_, err := w.Write(dataBytes)
+
+			checks.NoError(t, err, "Write error")
+		})
+	// create the test server
+	var err error
+	ts := server.OpenAITestServer()
+	ts.Start()
+	defer ts.Close()
+
+	config := DefaultAzureConfig(test.GetTestToken(), ts.URL)
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	request := ChatCompletionRequest{
+		MaxTokens: 5,
+		Model:     GPT3Dot5Turbo,
+		Messages: []ChatCompletionMessage{
+			{
+				Role:    ChatMessageRoleUser,
+				Content: "Hello!",
+			},
+		},
+		Stream: true,
+	}
+
+	apiErr := &APIError{}
+	_, err = client.CreateChatCompletionStream(ctx, request)
+	if !errors.As(err, &apiErr) {
+		t.Errorf("Did not return APIError: %+v\n", apiErr)
+		return
+	}
+	if apiErr.HTTPStatusCode != http.StatusTooManyRequests {
+		t.Errorf("Did not return HTTPStatusCode got = %d, want = %d\n", apiErr.HTTPStatusCode, http.StatusTooManyRequests)
+		return
+	}
+	code, ok := apiErr.Code.(string)
+	if !ok || code != wantCode {
+		t.Errorf("Did not return Code. got = %v, want = %s\n", apiErr.Code, wantCode)
+		return
+	}
+	if apiErr.Message != wantMessage {
+		t.Errorf("Did not return Message. got = %s, want = %s\n", apiErr.Message, wantMessage)
+		return
+	}
+}
+
 func TestCreateChatCompletionStreamErrorAccumulatorWriteErrors(t *testing.T) {
 	var err error
 	server := test.NewTestServer()
diff --git a/client_test.go b/client_test.go
index 5e63539..c96ceb7 100644
--- a/client_test.go
+++ b/client_test.go
@@ -134,6 +134,15 @@ func TestHandleErrorResp(t *testing.T) {
 				}`)),
 			expected: "error, status code: 503, message: That model...",
 		},
+		{
+			name:     "503 no message (Unknown response)",
+			httpCode: http.StatusServiceUnavailable,
+			body: bytes.NewReader([]byte(`
+				{
+					"error":{}
+				}`)),
+			expected: "error, status code: 503, message: ",
+		},
 	}
 
 	for _, tc := range testCases {
diff --git a/stream_test.go b/stream_test.go
index 589fc9e..0faa212 100644
--- a/stream_test.go
+++ b/stream_test.go
@@ -2,6 +2,7 @@ package openai_test
 
 import (
 	"context"
+	"encoding/json"
 	"errors"
 	"io"
 	"net/http"
@@ -217,6 +218,165 @@ func TestCreateCompletionStreamRateLimitError(t *testing.T) {
 	t.Logf("%+v\n", apiErr)
 }
 
+func TestCreateCompletionStreamTooManyEmptyStreamMessagesError(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+
+		// Send test responses
+		dataBytes := []byte{}
+		dataBytes = append(dataBytes, []byte("event: message\n")...)
+		//nolint:lll
+		data := `{"id":"1","object":"completion","created":1598069254,"model":"text-davinci-002","choices":[{"text":"response1","finish_reason":"max_tokens"}]}`
+		dataBytes = append(dataBytes, []byte("data: "+data+"\n\n")...)
+
+		// Totally 301 empty messages (300 is the limit)
+		for i := 0; i < 299; i++ {
+			dataBytes = append(dataBytes, '\n')
+		}
+
+		dataBytes = append(dataBytes, []byte("event: message\n")...)
+		//nolint:lll
+		data = `{"id":"2","object":"completion","created":1598069255,"model":"text-davinci-002","choices":[{"text":"response2","finish_reason":"max_tokens"}]}`
+		dataBytes = append(dataBytes, []byte("data: "+data+"\n\n")...)
+
+		dataBytes = append(dataBytes, []byte("event: done\n")...)
+		dataBytes = append(dataBytes, []byte("data: [DONE]\n\n")...)
+
+		_, err := w.Write(dataBytes)
+		checks.NoError(t, err, "Write error")
+	}))
+	defer server.Close()
+
+	// Client portion of the test
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = server.URL + "/v1"
+	config.HTTPClient.Transport = &test.TokenRoundTripper{
+		Token:    test.GetTestToken(),
+		Fallback: http.DefaultTransport,
+	}
+
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	request := CompletionRequest{
+		Prompt:    "Ex falso quodlibet",
+		Model:     "text-davinci-002",
+		MaxTokens: 10,
+		Stream:    true,
+	}
+
+	stream, err := client.CreateCompletionStream(ctx, request)
+	checks.NoError(t, err, "CreateCompletionStream returned error")
+	defer stream.Close()
+
+	_, _ = stream.Recv()
+	_, streamErr := stream.Recv()
+	if !errors.Is(streamErr, ErrTooManyEmptyStreamMessages) {
+		t.Errorf("TestCreateCompletionStreamTooManyEmptyStreamMessagesError did not return ErrTooManyEmptyStreamMessages")
+	}
+}
+
+func TestCreateCompletionStreamUnexpectedTerminatedError(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+
+		// Send test responses
+		dataBytes := []byte{}
+		dataBytes = append(dataBytes, []byte("event: message\n")...)
+		//nolint:lll
+		data := `{"id":"1","object":"completion","created":1598069254,"model":"text-davinci-002","choices":[{"text":"response1","finish_reason":"max_tokens"}]}`
+		dataBytes = append(dataBytes, []byte("data: "+data+"\n\n")...)
+
+		// Stream is terminated without sending "done" message
+
+		_, err := w.Write(dataBytes)
+		checks.NoError(t, err, "Write error")
+	}))
+	defer server.Close()
+
+	// Client portion of the test
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = server.URL + "/v1"
+	config.HTTPClient.Transport = &test.TokenRoundTripper{
+		Token:    test.GetTestToken(),
+		Fallback: http.DefaultTransport,
+	}
+
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	request := CompletionRequest{
+		Prompt:    "Ex falso quodlibet",
+		Model:     "text-davinci-002",
+		MaxTokens: 10,
+		Stream:    true,
+	}
+
+	stream, err := client.CreateCompletionStream(ctx, request)
+	checks.NoError(t, err, "CreateCompletionStream returned error")
+	defer stream.Close()
+
+	_, _ = stream.Recv()
+	_, streamErr := stream.Recv()
+	if !errors.Is(streamErr, io.EOF) {
+		t.Errorf("TestCreateCompletionStreamUnexpectedTerminatedError did not return io.EOF")
+	}
+}
+
+func TestCreateCompletionStreamBrokenJSONError(t *testing.T) {
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "text/event-stream")
+
+		// Send test responses
+		dataBytes := []byte{}
+		dataBytes = append(dataBytes, []byte("event: message\n")...)
+		//nolint:lll
+		data := `{"id":"1","object":"completion","created":1598069254,"model":"text-davinci-002","choices":[{"text":"response1","finish_reason":"max_tokens"}]}`
+		dataBytes = append(dataBytes, []byte("data: "+data+"\n\n")...)
+
+		// Send broken json
+		dataBytes = append(dataBytes, []byte("event: message\n")...)
+		data = `{"id":"2","object":"completion","created":1598069255,"model":`
+		dataBytes = append(dataBytes, []byte("data: "+data+"\n\n")...)
+
+		dataBytes = append(dataBytes, []byte("event: done\n")...)
+		dataBytes = append(dataBytes, []byte("data: [DONE]\n\n")...)
+
+		_, err := w.Write(dataBytes)
+		checks.NoError(t, err, "Write error")
+	}))
+	defer server.Close()
+
+	// Client portion of the test
+	config := DefaultConfig(test.GetTestToken())
+	config.BaseURL = server.URL + "/v1"
+	config.HTTPClient.Transport = &test.TokenRoundTripper{
+		Token:    test.GetTestToken(),
+		Fallback: http.DefaultTransport,
+	}
+
+	client := NewClientWithConfig(config)
+	ctx := context.Background()
+
+	request := CompletionRequest{
+		Prompt:    "Ex falso quodlibet",
+		Model:     "text-davinci-002",
+		MaxTokens: 10,
+		Stream:    true,
+	}
+
+	stream, err := client.CreateCompletionStream(ctx, request)
+	checks.NoError(t, err, "CreateCompletionStream returned error")
+	defer stream.Close()
+
+	_, _ = stream.Recv()
+	_, streamErr := stream.Recv()
+	var syntaxError *json.SyntaxError
+	if !errors.As(streamErr, &syntaxError) {
+		t.Errorf("TestCreateCompletionStreamBrokenJSONError did not return json.SyntaxError")
+	}
+}
+
 // Helper funcs.
 func compareResponses(r1, r2 CompletionResponse) bool {
 	if r1.ID != r2.ID || r1.Object != r2.Object || r1.Created != r2.Created || r1.Model != r2.Model {