| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/model"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- func TestApplyErrorPassthroughRule_NoBoundService(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- status, errType, errMsg, matched := applyErrorPassthroughRule(
- c,
- PlatformAnthropic,
- http.StatusUnprocessableEntity,
- []byte(`{"error":{"message":"invalid schema"}}`),
- http.StatusBadGateway,
- "upstream_error",
- "Upstream request failed",
- )
- assert.False(t, matched)
- assert.Equal(t, http.StatusBadGateway, status)
- assert.Equal(t, "upstream_error", errType)
- assert.Equal(t, "Upstream request failed", errMsg)
- }
- func TestGatewayHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- svc := &GatewayService{}
- respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
- resp := &http.Response{
- StatusCode: http.StatusUnprocessableEntity,
- Body: io.NopCloser(bytes.NewReader(respBody)),
- Header: http.Header{},
- }
- account := &Account{ID: 11, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
- require.Error(t, err)
- assert.Equal(t, http.StatusBadGateway, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, "upstream_error", errField["type"])
- assert.Equal(t, "Upstream request failed", errField["message"])
- }
- func TestOpenAIHandleErrorResponse_NoRuleKeepsDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- svc := &OpenAIGatewayService{}
- respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
- resp := &http.Response{
- StatusCode: http.StatusUnprocessableEntity,
- Body: io.NopCloser(bytes.NewReader(respBody)),
- Header: http.Header{},
- }
- account := &Account{ID: 12, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
- require.Error(t, err)
- assert.Equal(t, http.StatusBadGateway, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, "upstream_error", errField["type"])
- assert.Equal(t, "Upstream request failed", errField["message"])
- }
- func TestOpenAIHandleErrorResponse_ContextWindow502KeepsMessageWithoutFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
- svc := &OpenAIGatewayService{}
- respBody := []byte(`{"error":{"message":"Your input exceeds the context window of this model. Please adjust your input and try again.","type":"upstream_error","code":null}}`)
- resp := &http.Response{
- StatusCode: http.StatusBadGateway,
- Body: io.NopCloser(bytes.NewReader(respBody)),
- Header: http.Header{},
- }
- account := &Account{ID: 14, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.False(t, errors.As(err, &failoverErr))
- assert.Equal(t, http.StatusBadGateway, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, "upstream_error", errField["type"])
- assert.Equal(t, "Your input exceeds the context window of this model. Please adjust your input and try again.", errField["message"])
- }
- func TestGeminiWriteGeminiMappedError_NoRuleKeepsDefault(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- svc := &GeminiMessagesCompatService{}
- respBody := []byte(`{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}`)
- account := &Account{ID: 13, Platform: PlatformGemini, Type: AccountTypeAPIKey}
- err := svc.writeGeminiMappedError(c, account, http.StatusUnprocessableEntity, "req-2", respBody)
- require.Error(t, err)
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, "invalid_request_error", errField["type"])
- assert.Equal(t, "Upstream request failed", errField["message"])
- }
- func TestGatewayHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- ruleSvc := &ErrorPassthroughService{}
- ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "上游请求失败")})
- BindErrorPassthroughService(c, ruleSvc)
- svc := &GatewayService{}
- respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
- resp := &http.Response{
- StatusCode: http.StatusUnprocessableEntity,
- Body: io.NopCloser(bytes.NewReader(respBody)),
- Header: http.Header{},
- }
- account := &Account{ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
- require.Error(t, err)
- assert.Equal(t, http.StatusTeapot, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, "upstream_error", errField["type"])
- assert.Equal(t, "上游请求失败", errField["message"])
- }
- func TestOpenAIHandleErrorResponse_AppliesRuleFor422(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- ruleSvc := &ErrorPassthroughService{}
- ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "OpenAI上游失败")})
- BindErrorPassthroughService(c, ruleSvc)
- svc := &OpenAIGatewayService{}
- respBody := []byte(`{"error":{"message":"Invalid schema for field messages"}}`)
- resp := &http.Response{
- StatusCode: http.StatusUnprocessableEntity,
- Body: io.NopCloser(bytes.NewReader(respBody)),
- Header: http.Header{},
- }
- account := &Account{ID: 2, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
- require.Error(t, err)
- assert.Equal(t, http.StatusTeapot, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, "upstream_error", errField["type"])
- assert.Equal(t, "OpenAI上游失败", errField["message"])
- }
- func TestGeminiWriteGeminiMappedError_AppliesRuleFor422(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- ruleSvc := &ErrorPassthroughService{}
- ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{newNonFailoverPassthroughRule(http.StatusUnprocessableEntity, "invalid schema", http.StatusTeapot, "Gemini上游失败")})
- BindErrorPassthroughService(c, ruleSvc)
- svc := &GeminiMessagesCompatService{}
- respBody := []byte(`{"error":{"code":422,"message":"Invalid schema for field messages","status":"INVALID_ARGUMENT"}}`)
- account := &Account{ID: 3, Platform: PlatformGemini, Type: AccountTypeAPIKey}
- err := svc.writeGeminiMappedError(c, account, http.StatusUnprocessableEntity, "req-1", respBody)
- require.Error(t, err)
- assert.Equal(t, http.StatusTeapot, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok)
- assert.Equal(t, "upstream_error", errField["type"])
- assert.Equal(t, "Gemini上游失败", errField["message"])
- }
- func TestApplyErrorPassthroughRule_SkipMonitoringSetsContextKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- rule := newNonFailoverPassthroughRule(http.StatusBadRequest, "prompt is too long", http.StatusBadRequest, "上下文超限")
- rule.SkipMonitoring = true
- ruleSvc := &ErrorPassthroughService{}
- ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{rule})
- BindErrorPassthroughService(c, ruleSvc)
- _, _, _, matched := applyErrorPassthroughRule(
- c,
- PlatformAnthropic,
- http.StatusBadRequest,
- []byte(`{"error":{"message":"prompt is too long"}}`),
- http.StatusBadGateway,
- "upstream_error",
- "Upstream request failed",
- )
- assert.True(t, matched)
- v, exists := c.Get(OpsSkipPassthroughKey)
- assert.True(t, exists, "OpsSkipPassthroughKey should be set when skip_monitoring=true")
- boolVal, ok := v.(bool)
- assert.True(t, ok, "value should be bool")
- assert.True(t, boolVal)
- }
- func TestApplyErrorPassthroughRule_NoSkipMonitoringDoesNotSetContextKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- rule := newNonFailoverPassthroughRule(http.StatusBadRequest, "prompt is too long", http.StatusBadRequest, "上下文超限")
- rule.SkipMonitoring = false
- ruleSvc := &ErrorPassthroughService{}
- ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{rule})
- BindErrorPassthroughService(c, ruleSvc)
- _, _, _, matched := applyErrorPassthroughRule(
- c,
- PlatformAnthropic,
- http.StatusBadRequest,
- []byte(`{"error":{"message":"prompt is too long"}}`),
- http.StatusBadGateway,
- "upstream_error",
- "Upstream request failed",
- )
- assert.True(t, matched)
- _, exists := c.Get(OpsSkipPassthroughKey)
- assert.False(t, exists, "OpsSkipPassthroughKey should NOT be set when skip_monitoring=false")
- }
- // ---- ResponseCommittedKey: service 层写完错误响应后标记,handler 层检查跳过兜底写入 ----
- func TestHandleErrorResponse_SetsResponseCommitted(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- svc := &GatewayService{}
- resp := &http.Response{
- StatusCode: http.StatusBadRequest,
- Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"temperature: range: 0..1"}}`))),
- Header: http.Header{},
- }
- account := &Account{ID: 100, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
- require.Error(t, err)
- assert.True(t, IsResponseCommitted(c), "non-failover error path must mark response committed")
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- }
- func TestHandleErrorResponse_PassthroughRuleSetsCommitted(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- ruleSvc := &ErrorPassthroughService{}
- ruleSvc.setLocalCache([]*model.ErrorPassthroughRule{
- newNonFailoverPassthroughRule(http.StatusBadRequest, "temperature", http.StatusBadRequest, "参数错误"),
- })
- BindErrorPassthroughService(c, ruleSvc)
- svc := &GatewayService{}
- resp := &http.Response{
- StatusCode: http.StatusBadRequest,
- Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"temperature: range: 0..1"}}`))),
- Header: http.Header{},
- }
- account := &Account{ID: 200, Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account)
- require.Error(t, err)
- assert.True(t, IsResponseCommitted(c), "passthrough rule path must mark response committed")
- assert.Equal(t, http.StatusBadRequest, rec.Code)
- var payload map[string]any
- require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
- errField, ok := payload["error"].(map[string]any)
- require.True(t, ok, "payload[\"error\"] should be map[string]any")
- assert.Equal(t, "参数错误", errField["message"])
- }
- func TestOpenAIHandleErrorResponse_SetsResponseCommitted(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- svc := &OpenAIGatewayService{}
- resp := &http.Response{
- StatusCode: http.StatusTooManyRequests,
- Body: io.NopCloser(bytes.NewReader([]byte(`{"error":{"message":"rate limit exceeded"}}`))),
- Header: http.Header{},
- }
- account := &Account{ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
- _, err := svc.handleErrorResponse(context.Background(), resp, c, account, nil)
- require.Error(t, err)
- assert.True(t, IsResponseCommitted(c), "OpenAI non-failover path must mark response committed")
- }
- func TestGeminiWriteGeminiMappedError_SetsResponseCommitted(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- svc := &GeminiMessagesCompatService{}
- body := []byte(`{"error":{"message":"invalid field"}}`)
- account := &Account{ID: 102, Platform: PlatformGemini, Type: AccountTypeAPIKey}
- err := svc.writeGeminiMappedError(c, account, http.StatusBadRequest, "req-99", body)
- require.Error(t, err)
- assert.True(t, IsResponseCommitted(c), "Gemini path must mark response committed")
- }
- func newNonFailoverPassthroughRule(statusCode int, keyword string, respCode int, customMessage string) *model.ErrorPassthroughRule {
- return &model.ErrorPassthroughRule{
- ID: 1,
- Name: "non-failover-rule",
- Enabled: true,
- Priority: 1,
- ErrorCodes: []int{statusCode},
- Keywords: []string{keyword},
- MatchMode: model.MatchModeAll,
- PassthroughCode: false,
- ResponseCode: &respCode,
- PassthroughBody: false,
- CustomMessage: &customMessage,
- }
- }
|