Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pkg/providers/azure/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type (

const (
defaultRequestTimeout = common.DefaultRequestTimeout
responsesAPIPath = "openai/v1/responses"
)

// Provider implements the LLM provider interface for Azure OpenAI endpoints.
Expand Down Expand Up @@ -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)
}
Expand Down
37 changes: 37 additions & 0 deletions pkg/providers/azure/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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{
Expand Down
7 changes: 6 additions & 1 deletion pkg/providers/openai_responses_common/responses_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 43 additions & 21 deletions pkg/providers/openai_responses_common/responses_common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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",
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -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 {
Expand All @@ -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")
}
}

Expand Down
Loading