| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- package service
- import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- type nonJSONTempUnschedAccountRepo struct {
- AccountRepository
- tempUnschedCalls int
- tempReason string
- modelRateLimitCalls int
- modelScope string
- modelReason string
- }
- func (r *nonJSONTempUnschedAccountRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
- r.tempUnschedCalls++
- r.tempReason = reason
- return nil
- }
- func (r *nonJSONTempUnschedAccountRepo) SetModelRateLimit(_ context.Context, _ int64, scope string, _ time.Time, reason ...string) error {
- r.modelRateLimitCalls++
- r.modelScope = scope
- if len(reason) > 0 {
- r.modelReason = reason[0]
- }
- return nil
- }
- func TestHandleNonStreamingResponse_NonJSON2xxTriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- body := []byte("(upstream request failed)")
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/plain"},
- "X-Request-Id": []string{"rid-invalid-json"},
- },
- Body: io.NopCloser(bytes.NewReader(body)),
- }
- svc := &GatewayService{
- cfg: &config.Config{},
- rateLimitService: &RateLimitService{},
- }
- usage, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, "claude-sonnet-4-6", "claude-sonnet-4-6")
- require.Nil(t, usage)
- var failoverErr *UpstreamFailoverError
- require.True(t, errors.As(err, &failoverErr))
- require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
- require.Equal(t, body, failoverErr.ResponseBody)
- require.Equal(t, "rid-invalid-json", failoverErr.ResponseHeaders.Get("x-request-id"))
- require.False(t, c.Writer.Written(), "invalid upstream response must not be committed before failover")
- }
- func TestHandleNonStreamingResponse_ValidJSONUnchanged(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- body := []byte(`{"id":"msg_1","type":"message","usage":{"input_tokens":12,"output_tokens":7}}`)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(bytes.NewReader(body)),
- }
- svc := &GatewayService{
- cfg: &config.Config{},
- rateLimitService: &RateLimitService{},
- }
- usage, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, "claude-sonnet-4-6", "claude-sonnet-4-6")
- require.NoError(t, err)
- require.NotNil(t, usage)
- require.Equal(t, 12, usage.InputTokens)
- require.Equal(t, 7, usage.OutputTokens)
- require.JSONEq(t, string(body), rec.Body.String())
- }
- func TestHandleNonStreamingResponseAnthropicAPIKeyPassthrough_NonJSON2xxTriggersFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- body := []byte("(upstream request failed)")
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/plain"}},
- Body: io.NopCloser(bytes.NewReader(body)),
- }
- svc := &GatewayService{cfg: &config.Config{}}
- usage, err := svc.handleNonStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 2})
- require.Nil(t, usage)
- var failoverErr *UpstreamFailoverError
- require.True(t, errors.As(err, &failoverErr))
- require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
- require.Equal(t, body, failoverErr.ResponseBody)
- require.False(t, c.Writer.Written(), "invalid passthrough response must not be committed before failover")
- }
- func TestHandleNonStreamingResponseAnthropicAPIKeyPassthrough_ValidJSONUnchanged(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- body := []byte(`{"id":"msg_1","type":"message","usage":{"input_tokens":5,"output_tokens":3}}`)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(bytes.NewReader(body)),
- }
- svc := &GatewayService{cfg: &config.Config{}}
- usage, err := svc.handleNonStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 2})
- require.NoError(t, err)
- require.NotNil(t, usage)
- require.Equal(t, 5, usage.InputTokens)
- require.Equal(t, 3, usage.OutputTokens)
- require.JSONEq(t, string(body), rec.Body.String())
- }
- func TestHandleNonStreamingResponseAnthropicAPIKeyPassthrough_ForceCacheBillingResponse(t *testing.T) {
- tests := []struct {
- name string
- body string
- want string
- }{
- {
- name: "converts input tokens for downstream billing",
- body: `{"id":"msg_1","type":"message","content":[{"type":"text","text":"unchanged"}],"usage":{"input_tokens":5,"output_tokens":3}}`,
- want: `{"id":"msg_1","type":"message","content":[{"type":"text","text":"unchanged"}],"usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":5}}`,
- },
- {
- name: "adds to genuine cache reads",
- body: `{"id":"msg_2","type":"message","usage":{"input_tokens":5,"output_tokens":3,"cache_read_input_tokens":7,"cache_creation_input_tokens":11}}`,
- want: `{"id":"msg_2","type":"message","usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":12,"cache_creation_input_tokens":11}}`,
- },
- {
- name: "zero input leaves response unchanged",
- body: `{"id":"msg_3","type":"message","usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":7}}`,
- want: `{"id":"msg_3","type":"message","usage":{"input_tokens":0,"output_tokens":3,"cache_read_input_tokens":7}}`,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(bytes.NewBufferString(tt.body)),
- }
- svc := &GatewayService{cfg: &config.Config{}}
- usage, err := svc.handleNonStreamingResponseAnthropicAPIKeyPassthrough(WithForceCacheBilling(context.Background()), resp, c, &Account{ID: 2})
- require.NoError(t, err)
- require.Equal(t, int(gjson.Get(tt.body, "usage.input_tokens").Int()), usage.InputTokens, "local accounting must retain the unclassified usage")
- require.Equal(t, int(gjson.Get(tt.body, "usage.cache_read_input_tokens").Int()), usage.CacheReadInputTokens, "local accounting must convert exactly once in RecordUsage")
- require.JSONEq(t, tt.want, rec.Body.String())
- })
- }
- }
- func TestHandleNonStreamingResponse_NonJSON2xxMatchesModelScopedTempUnschedulableRule(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- repo := &nonJSONTempUnschedAccountRepo{}
- rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- svc := &GatewayService{
- cfg: &config.Config{},
- rateLimitService: rateLimitService,
- }
- account := &Account{
- ID: 3,
- Platform: PlatformAnthropic,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "temp_unschedulable_enabled": true,
- "temp_unschedulable_rules": []any{
- map[string]any{
- "error_code": float64(http.StatusBadGateway),
- "keywords": []any{"upstream request failed"},
- "duration_minutes": float64(10),
- },
- },
- },
- }
- body := []byte("(upstream request failed)")
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{},
- Body: io.NopCloser(bytes.NewReader(body)),
- }
- _, err := svc.handleNonStreamingResponse(context.Background(), resp, c, account, "claude-sonnet-4-6", "claude-sonnet-4-6")
- var failoverErr *UpstreamFailoverError
- require.True(t, errors.As(err, &failoverErr))
- require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
- require.Equal(t, body, failoverErr.ResponseBody)
- require.Zero(t, repo.tempUnschedCalls)
- require.Equal(t, 1, repo.modelRateLimitCalls)
- require.Equal(t, "claude-sonnet-4-6", repo.modelScope)
- require.Contains(t, repo.modelReason, `"status_code":502`)
- require.Contains(t, repo.modelReason, `"matched_keyword":"upstream request failed"`)
- }
|