From 40388c4a71aa1e458ac2bb2566f5ad5aa6b75b05 Mon Sep 17 00:00:00 2001 From: euvre <93761161+euvre@users.noreply.github.com> Date: Fri, 24 Jul 2026 09:54:08 +0800 Subject: [PATCH] fix(models): surface MiniMax rate-limit and API errors instead of generic messages (#17292) --- internal/entity/models/minimax.go | 62 +++++++++++++++++- internal/entity/models/minimax_test.go | 90 ++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/internal/entity/models/minimax.go b/internal/entity/models/minimax.go index 144e46c4af..e947ace845 100644 --- a/internal/entity/models/minimax.go +++ b/internal/entity/models/minimax.go @@ -59,6 +59,50 @@ func validateMinimaxModelName(modelName string) (string, error) { return strings.TrimSpace(modelName), nil } +// extractMinimaxAPIError checks a parsed MiniMax response for +// provider-specific or OpenAI-compatible error payloads. +// +// MiniMax can embed errors in the response body even when the HTTP +// status is 200 — the classic case is rate limiting, where the body +// carries a `base_resp` block with a non-zero `status_code` instead +// of the expected `choices` array. Returning the original message +// (which typically contains "rate limit" / "frequency limit" / "429") +// lets the upstream retry predicates match and retry appropriately. +// +// Returns the error description, or "" when no error is present. +func extractMinimaxAPIError(result map[string]interface{}) string { + if br, ok := result["base_resp"].(map[string]interface{}); ok { + if sc, _ := br["status_code"].(float64); sc != 0 { + msg, _ := br["status_msg"].(string) + if msg == "" { + return fmt.Sprintf("status_code %v", sc) + } + return msg + } + } + if e, ok := result["error"].(map[string]interface{}); ok { + if msg, _ := e["message"].(string); msg != "" { + return msg + } + } + return "" +} + +// extractMinimaxErrorBody parses a raw HTTP error response body and +// returns the human-readable error message when the body is a +// MiniMax or OpenAI-compatible error JSON. Falls back to the raw +// body string when parsing fails so no information is lost. +func extractMinimaxErrorBody(body []byte) string { + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return string(body) + } + if msg := extractMinimaxAPIError(result); msg != "" { + return msg + } + return string(body) +} + // ChatWithMessages sends multiple messages with roles and returns response func (m *MinimaxModel) ChatWithMessages(ctx context.Context, modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, modelUsage *common.ModelUsage) (*ChatResponse, error) { if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { @@ -126,7 +170,7 @@ func (m *MinimaxModel) ChatWithMessages(ctx context.Context, modelName string, m } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to send request: %d %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("minimax API error: status %d: %s", resp.StatusCode, extractMinimaxErrorBody(body)) } // Parse response @@ -135,6 +179,11 @@ func (m *MinimaxModel) ChatWithMessages(ctx context.Context, modelName string, m return nil, fmt.Errorf("failed to unmarshal response: %w", err) } + // MiniMax can embed an error (rate limit, etc.) inside a 200 body. + if errMsg := extractMinimaxAPIError(result); errMsg != "" { + return nil, fmt.Errorf("minimax API error: %s", errMsg) + } + choices, ok := result["choices"].([]interface{}) if !ok || len(choices) == 0 { return nil, fmt.Errorf("no choices in response") @@ -252,7 +301,7 @@ func (m *MinimaxModel) ChatStreamlyWithSender(ctx context.Context, modelName str if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + return fmt.Errorf("minimax API error: status %d: %s", resp.StatusCode, extractMinimaxErrorBody(body)) } // SSE parsing: read line by line @@ -261,6 +310,13 @@ func (m *MinimaxModel) ChatStreamlyWithSender(ctx context.Context, modelName str done, err := ParseSSEStream[map[string]interface{}](resp.Body, func(event map[string]interface{}) error { choices, ok := event["choices"].([]interface{}) if !ok || len(choices) == 0 { + // MiniMax can send an error event (rate limit, etc.) + // without a choices array. Surface it so the retry + // predicates can match and the caller sees the real + // reason instead of a generic "stream ended" error. + if errMsg := extractMinimaxAPIError(event); errMsg != "" { + return fmt.Errorf("minimax API error: %s", errMsg) + } return nil } @@ -351,7 +407,7 @@ func (m *MinimaxModel) ListModels(ctx context.Context, apiConfig *APIConfig) ([] } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("minimax API error: status %d: %s", resp.StatusCode, extractMinimaxErrorBody(body)) } // Parse response diff --git a/internal/entity/models/minimax_test.go b/internal/entity/models/minimax_test.go index fc922d2d39..5deff64f9e 100644 --- a/internal/entity/models/minimax_test.go +++ b/internal/entity/models/minimax_test.go @@ -153,6 +153,96 @@ func TestMinimaxChatRejectsEmptyChoices(t *testing.T) { } } +func TestMinimaxChatSurfacesBaseRespError(t *testing.T) { + ctx := t.Context() + srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { + // MiniMax rate-limit: HTTP 200 with base_resp, no choices. + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "base_resp": map[string]interface{}{ + "status_code": 1027, + "status_msg": "frequency limit reached", + }, + }) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newMinimaxForTest(srv.URL).ChatWithMessages( + ctx, + "MiniMax-M3", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &apiKey}, + nil, + nil, + ) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "frequency limit reached") { + t.Fatalf("expected rate-limit message, got %v", err) + } +} + +func TestMinimaxChatSurfacesOpenAIError(t *testing.T) { + ctx := t.Context() + srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { + w.WriteHeader(http.StatusTooManyRequests) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "rate limit exceeded", + "type": "rate_limit_error", + }, + }) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newMinimaxForTest(srv.URL).ChatWithMessages( + ctx, + "MiniMax-M3", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &apiKey}, + nil, + nil, + ) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "rate limit exceeded") { + t.Fatalf("expected rate-limit message, got %v", err) + } + if !strings.Contains(err.Error(), "429") { + t.Fatalf("expected status code 429 in error, got %v", err) + } +} + +func TestMinimaxStreamSurfacesBaseRespError(t *testing.T) { + ctx := t.Context() + srv := newMinimaxServer(t, func(t *testing.T, _ *http.Request, _ map[string]interface{}, w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/event-stream") + // Error event before any choices — rate limit. + _, _ = io.WriteString(w, `data: {"base_resp":{"status_code":1027,"status_msg":"frequency limit reached"}}`+"\n") + }) + defer srv.Close() + + apiKey := "test-key" + err := newMinimaxForTest(srv.URL).ChatStreamlyWithSender( + ctx, + "MiniMax-M3", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &apiKey}, + nil, + nil, + func(*string, *string) error { return nil }, + ) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "frequency limit reached") { + t.Fatalf("expected rate-limit message, got %v", err) + } +} + func TestMinimaxStreamForcesStreaming(t *testing.T) { ctx := t.Context() srv := newMinimaxServer(t, func(t *testing.T, r *http.Request, body map[string]interface{}, w http.ResponseWriter) {