diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go index 9d29a90cd..429b26798 100644 --- a/pkg/providers/azure/provider.go +++ b/pkg/providers/azure/provider.go @@ -26,6 +26,7 @@ type ( const ( defaultRequestTimeout = common.DefaultRequestTimeout + responsesAPIPath = "openai/v1/responses" ) // Provider implements the LLM provider interface for Azure OpenAI endpoints. @@ -87,7 +88,7 @@ func (p *Provider) Chat( return nil, fmt.Errorf("Azure API base not configured") } - requestURL, err := url.JoinPath(p.apiBase, "openai/v1/responses") + requestURL, err := url.JoinPath(p.apiBase, responsesAPIPath) if err != nil { return nil, fmt.Errorf("failed to build Azure request URL: %w", err) } diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go index e57d68057..b3752ea50 100644 --- a/pkg/providers/azure/provider_test.go +++ b/pkg/providers/azure/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -167,6 +168,42 @@ func TestProviderChat_AzureHTTPError(t *testing.T) { } } +func TestProviderChat_AzureRateLimitError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error":{"message":"Rate limit exceeded","type":"rate_limit_error"}}`)) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error for 429, got nil") + } + if !strings.Contains(err.Error(), "429") { + t.Errorf("error should contain status code 429, got: %v", err) + } +} + +func TestProviderChat_AzureServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":{"message":"Internal server error","type":"server_error"}}`)) + })) + defer server.Close() + + p := NewProvider("test-key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) + if err == nil { + t.Fatal("expected error for 500, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("error should contain status code 500, got: %v", err) + } +} + func TestProviderChat_AzureParseTextOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ diff --git a/pkg/providers/openai_responses_common/responses_common.go b/pkg/providers/openai_responses_common/responses_common.go index 29133a51e..839471f69 100644 --- a/pkg/providers/openai_responses_common/responses_common.go +++ b/pkg/providers/openai_responses_common/responses_common.go @@ -268,8 +268,13 @@ func parseResponse(apiResp *responses.Response) *protocoltypes.LLMResponse { if len(toolCalls) > 0 { finishReason = "tool_calls" } - if apiResp.Status == "incomplete" { + switch apiResp.Status { + case responses.ResponseStatusIncomplete: finishReason = "length" + case responses.ResponseStatusFailed: + finishReason = "error" + case responses.ResponseStatusCancelled: + finishReason = "canceled" } var usage *protocoltypes.UsageInfo diff --git a/pkg/providers/openai_responses_common/responses_common_test.go b/pkg/providers/openai_responses_common/responses_common_test.go index be10e8427..0d41190b1 100644 --- a/pkg/providers/openai_responses_common/responses_common_test.go +++ b/pkg/providers/openai_responses_common/responses_common_test.go @@ -2,9 +2,12 @@ package openai_responses_common import ( "encoding/json" + "fmt" "strings" "testing" + "github.com/openai/openai-go/v3/responses" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) @@ -298,10 +301,10 @@ func TestTranslateTools_DescriptionOmittedWhenEmpty(t *testing.T) { // --- ParseResponseBody tests --- func TestParseResponseBody_TextOutput(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_123", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "message", @@ -315,7 +318,7 @@ func TestParseResponseBody_TextOutput(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -333,10 +336,10 @@ func TestParseResponseBody_TextOutput(t *testing.T) { } func TestParseResponseBody_FunctionCall(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_456", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "function_call", @@ -352,7 +355,7 @@ func TestParseResponseBody_FunctionCall(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -373,10 +376,10 @@ func TestParseResponseBody_FunctionCall(t *testing.T) { } func TestParseResponseBody_Reasoning(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_789", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "reasoning", @@ -395,7 +398,7 @@ func TestParseResponseBody_Reasoning(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 10} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -410,10 +413,10 @@ func TestParseResponseBody_Reasoning(t *testing.T) { } func TestParseResponseBody_Refusal(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_ref", "object": "response", - "status": "completed", + "status": "%s", "output": [ { "type": "message", @@ -427,7 +430,7 @@ func TestParseResponseBody_Refusal(t *testing.T) { "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0} } - }`) + }`, string(responses.ResponseStatusCompleted))) result, err := ParseResponseBody(body) if err != nil { @@ -439,10 +442,10 @@ func TestParseResponseBody_Refusal(t *testing.T) { } func TestParseResponseBody_IncompleteStatus(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_inc", "object": "response", - "status": "incomplete", + "status": "%s", "output": [ { "type": "message", @@ -452,7 +455,7 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { "usage": {"input_tokens": 5, "output_tokens": 2, "total_tokens": 7, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0}} - }`) + }`, string(responses.ResponseStatusIncomplete))) result, err := ParseResponseBody(body) if err != nil { @@ -464,23 +467,42 @@ func TestParseResponseBody_IncompleteStatus(t *testing.T) { } func TestParseResponseBody_FailedStatus(t *testing.T) { - body := strings.NewReader(`{ + body := strings.NewReader(fmt.Sprintf(`{ "id": "resp_fail", "object": "response", - "status": "failed", + "status": "%s", "output": [], "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "input_tokens_details": {"cached_tokens": 0}, "output_tokens_details": {"reasoning_tokens": 0}} - }`) + }`, string(responses.ResponseStatusFailed))) result, err := ParseResponseBody(body) if err != nil { t.Fatalf("error: %v", err) } - // failed/canceled statuses are not specially mapped; they fall through to "stop" - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") + if result.FinishReason != "error" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "error") + } +} + +func TestParseResponseBody_CanceledStatus(t *testing.T) { + body := strings.NewReader(fmt.Sprintf(`{ + "id": "resp_cancel", + "object": "response", + "status": "%s", + "output": [], + "usage": {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens_details": {"reasoning_tokens": 0}} + }`, string(responses.ResponseStatusCancelled))) + + result, err := ParseResponseBody(body) + if err != nil { + t.Fatalf("error: %v", err) + } + if result.FinishReason != "canceled" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "canceled") } }