| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345 |
- package service
- import (
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- type gatewayForwardErrorPolicyRepoStub struct {
- AccountRepository
- tempCalls int
- overloadCalls int
- modelRateLimitCalls []gatewayForwardModelRateLimitCall
- }
- type gatewayForwardModelRateLimitCall struct {
- accountID int64
- scope string
- }
- func (r *gatewayForwardErrorPolicyRepoStub) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
- r.tempCalls++
- return nil
- }
- func (r *gatewayForwardErrorPolicyRepoStub) SetModelRateLimit(_ context.Context, id int64, scope string, _ time.Time, _ ...string) error {
- r.modelRateLimitCalls = append(r.modelRateLimitCalls, gatewayForwardModelRateLimitCall{
- accountID: id,
- scope: scope,
- })
- return nil
- }
- func (r *gatewayForwardErrorPolicyRepoStub) SetOverloaded(context.Context, int64, time.Time) error {
- r.overloadCalls++
- return nil
- }
- // 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时,
- // 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同
- // 返回,供 handler 照常提交 usage 记录。
- func newForwardPartialUsageServiceForTest(upstream *anthropicHTTPUpstreamRecorder) *GatewayService {
- cfg := &config.Config{
- Gateway: config.GatewayConfig{
- MaxLineSize: defaultMaxLineSize,
- },
- }
- return &GatewayService{
- cfg: cfg,
- responseHeaderFilter: compileResponseHeaderFilter(cfg),
- httpUpstream: upstream,
- rateLimitService: &RateLimitService{},
- deferredService: &DeferredService{},
- }
- }
- func newAnthropicOAuthAccountForPartialUsageTest() *Account {
- return &Account{
- ID: 501,
- Name: "anthropic-oauth-partial-usage",
- Platform: PlatformAnthropic,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- },
- Status: StatusActive,
- Schedulable: true,
- }
- }
- func TestGatewayService_Forward_StreamMissingTerminalPreservesPartialUsage(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(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
- require.NoError(t, err)
- // newapi 等聚合上游的典型失败形态:message_start/message_delta 携带 usage,
- // 但流在 message_stop 前直接结束。
- upstreamSSE := strings.Join([]string{
- `event: message_start`,
- `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-latest","content":[],"usage":{"input_tokens":11,"cache_read_input_tokens":7}}}`,
- "",
- `event: content_block_delta`,
- `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`,
- "",
- `event: message_delta`,
- `data: {"type":"message_delta","delta":{"stop_reason":null},"usage":{"output_tokens":5}}`,
- "",
- "",
- }, "\n")
- upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/event-stream"},
- "X-Request-Id": []string{"rid-partial"},
- },
- Body: io.NopCloser(strings.NewReader(upstreamSSE)),
- }}
- svc := newForwardPartialUsageServiceForTest(upstream)
- account := newAnthropicOAuthAccountForPartialUsageTest()
- result, err := svc.Forward(context.Background(), c, account, parsed)
- require.Error(t, err)
- require.Contains(t, err.Error(), "missing terminal event")
- require.NotNil(t, result, "流中断但已观测到 usage 时必须返回部分结果用于计费")
- require.True(t, result.Stream)
- require.Equal(t, 11, result.Usage.InputTokens)
- require.Equal(t, 7, result.Usage.CacheReadInputTokens)
- require.Equal(t, 5, result.Usage.OutputTokens)
- require.Equal(t, "rid-partial", result.RequestID)
- require.NotNil(t, result.FirstTokenMs)
- }
- func TestGatewayService_Forward_StreamReadErrorAfterOutputPreservesPartialUsage(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(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
- require.NoError(t, err)
- // message_start 已写出(含 usage),随后上游连接异常中断。
- upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: &streamReadCloser{
- payload: []byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":4}}}\n\n"),
- err: io.ErrUnexpectedEOF,
- },
- }}
- svc := newForwardPartialUsageServiceForTest(upstream)
- account := newAnthropicOAuthAccountForPartialUsageTest()
- result, err := svc.Forward(context.Background(), c, account, parsed)
- require.Error(t, err)
- require.Contains(t, err.Error(), "stream read error")
- require.NotNil(t, result, "已写出内容后的读错误必须保留部分 usage")
- require.Equal(t, 9, result.Usage.InputTokens)
- require.Equal(t, 4, result.Usage.CacheCreationInputTokens)
- }
- func TestGatewayService_Forward_StreamErrorWithoutUsageReturnsNilResult(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(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
- require.NoError(t, err)
- // 只有 ping、没有任何 usage 的流中断:不应产生零 usage 的幽灵账单记录。
- upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader("event: ping\ndata: {\"type\": \"ping\"}\n\n")),
- }}
- svc := newForwardPartialUsageServiceForTest(upstream)
- account := newAnthropicOAuthAccountForPartialUsageTest()
- result, err := svc.Forward(context.Background(), c, account, parsed)
- require.Error(t, err)
- require.Contains(t, err.Error(), "missing terminal event")
- require.Nil(t, result, "无已观测 usage 时不应返回部分结果")
- }
- func TestGatewayService_Forward_FailoverErrorKeepsNilResult(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(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
- require.NoError(t, err)
- // 未向客户端写出任何字节前的读错误会包成 UpstreamFailoverError 走换号重试。
- // 该路径必须保持 result=nil:failover 成功后按成功请求计费,双份结果会重复计费。
- upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: &streamReadCloser{
- err: errors.New("connection reset by peer"),
- },
- }}
- svc := newForwardPartialUsageServiceForTest(upstream)
- account := newAnthropicOAuthAccountForPartialUsageTest()
- result, err := svc.Forward(context.Background(), c, account, parsed)
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.True(t, errors.As(err, &failoverErr))
- require.Nil(t, result, "failover 错误必须保持 result=nil,防止重试成功后双重计费")
- }
- func TestGatewayService_Forward_PreOutputSSEOverloadedErrorUsesSemantic529(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(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`)
- parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
- require.NoError(t, err)
- const errorJSON = `{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_01"}`
- upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader("event: error\ndata: " + errorJSON + "\n\n")),
- }}
- repo := &gatewayForwardErrorPolicyRepoStub{}
- cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
- svc := &GatewayService{
- cfg: cfg,
- responseHeaderFilter: compileResponseHeaderFilter(cfg),
- httpUpstream: upstream,
- rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
- deferredService: &DeferredService{},
- }
- account := newAnthropicOAuthAccountForPartialUsageTest()
- account.Credentials["temp_unschedulable_enabled"] = true
- account.Credentials["temp_unschedulable_rules"] = []any{map[string]any{
- "error_code": float64(529),
- "keywords": []any{"Overloaded"},
- "duration_minutes": float64(10),
- }}
- result, err := svc.Forward(context.Background(), c, account, parsed)
- require.Error(t, err)
- require.Nil(t, result)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, 529, failoverErr.StatusCode)
- require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
- require.Equal(t, 1, repo.overloadCalls, "synthetic 529 must apply global overload cooldown")
- require.Empty(t, repo.modelRateLimitCalls, "global 529 cooldown must take precedence over custom model rules")
- require.Empty(t, rec.Body.String(), "pre-output overload must remain eligible for account failover")
- }
- func TestGatewayService_Forward_PostOutputSSEOverloadedErrorKeepsExistingStatus(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(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`)
- parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
- require.NoError(t, err)
- const errorJSON = `{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`
- fixture := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" +
- "event: error\ndata: " + errorJSON + "\n\n"
- upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(fixture)),
- }}
- repo := &gatewayForwardErrorPolicyRepoStub{}
- cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
- svc := &GatewayService{
- cfg: cfg,
- responseHeaderFilter: compileResponseHeaderFilter(cfg),
- httpUpstream: upstream,
- rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
- deferredService: &DeferredService{},
- }
- result, err := svc.Forward(context.Background(), c, newAnthropicOAuthAccountForPartialUsageTest(), parsed)
- require.Error(t, err)
- require.Nil(t, result)
- var failoverErr *UpstreamFailoverError
- require.ErrorAs(t, err, &failoverErr)
- require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
- require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
- require.Zero(t, repo.tempCalls)
- require.Contains(t, rec.Body.String(), "message_start")
- }
- func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamMissingTerminalPreservesPartialUsage(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(`{"model":"claude-3-7-sonnet-20250219","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
- parsed := &ParsedRequest{
- Body: NewRequestBodyRef(body),
- Model: "claude-3-7-sonnet-20250219",
- Stream: true,
- }
- upstreamSSE := strings.Join([]string{
- `data: {"type":"message_start","message":{"usage":{"input_tokens":9,"cache_read_input_tokens":2}}}`,
- "",
- `data: {"type":"message_delta","usage":{"output_tokens":3}}`,
- "",
- }, "\n")
- upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/event-stream"},
- "X-Request-Id": []string{"rid-pass-partial"},
- },
- Body: io.NopCloser(strings.NewReader(upstreamSSE)),
- }}
- svc := newForwardPartialUsageServiceForTest(upstream)
- account := newAnthropicAPIKeyAccountForTest()
- result, err := svc.Forward(context.Background(), c, account, parsed)
- require.Error(t, err)
- require.Contains(t, err.Error(), "missing terminal event")
- require.NotNil(t, result, "透传流中断但已观测到 usage 时必须返回部分结果用于计费")
- require.True(t, result.Stream)
- require.Equal(t, 9, result.Usage.InputTokens)
- require.Equal(t, 2, result.Usage.CacheReadInputTokens)
- require.Equal(t, 3, result.Usage.OutputTokens)
- require.Equal(t, "claude-3-7-sonnet-20250219", result.Model)
- }
|