gateway_forward_partial_usage_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "time"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. )
  15. type gatewayForwardErrorPolicyRepoStub struct {
  16. AccountRepository
  17. tempCalls int
  18. overloadCalls int
  19. modelRateLimitCalls []gatewayForwardModelRateLimitCall
  20. }
  21. type gatewayForwardModelRateLimitCall struct {
  22. accountID int64
  23. scope string
  24. }
  25. func (r *gatewayForwardErrorPolicyRepoStub) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
  26. r.tempCalls++
  27. return nil
  28. }
  29. func (r *gatewayForwardErrorPolicyRepoStub) SetModelRateLimit(_ context.Context, id int64, scope string, _ time.Time, _ ...string) error {
  30. r.modelRateLimitCalls = append(r.modelRateLimitCalls, gatewayForwardModelRateLimitCall{
  31. accountID: id,
  32. scope: scope,
  33. })
  34. return nil
  35. }
  36. func (r *gatewayForwardErrorPolicyRepoStub) SetOverloaded(context.Context, int64, time.Time) error {
  37. r.overloadCalls++
  38. return nil
  39. }
  40. // 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时,
  41. // 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同
  42. // 返回,供 handler 照常提交 usage 记录。
  43. func newForwardPartialUsageServiceForTest(upstream *anthropicHTTPUpstreamRecorder) *GatewayService {
  44. cfg := &config.Config{
  45. Gateway: config.GatewayConfig{
  46. MaxLineSize: defaultMaxLineSize,
  47. },
  48. }
  49. return &GatewayService{
  50. cfg: cfg,
  51. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  52. httpUpstream: upstream,
  53. rateLimitService: &RateLimitService{},
  54. deferredService: &DeferredService{},
  55. }
  56. }
  57. func newAnthropicOAuthAccountForPartialUsageTest() *Account {
  58. return &Account{
  59. ID: 501,
  60. Name: "anthropic-oauth-partial-usage",
  61. Platform: PlatformAnthropic,
  62. Type: AccountTypeOAuth,
  63. Concurrency: 1,
  64. Credentials: map[string]any{
  65. "access_token": "oauth-token",
  66. },
  67. Status: StatusActive,
  68. Schedulable: true,
  69. }
  70. }
  71. func TestGatewayService_Forward_StreamMissingTerminalPreservesPartialUsage(t *testing.T) {
  72. gin.SetMode(gin.TestMode)
  73. rec := httptest.NewRecorder()
  74. c, _ := gin.CreateTestContext(rec)
  75. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  76. body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  77. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  78. require.NoError(t, err)
  79. // newapi 等聚合上游的典型失败形态:message_start/message_delta 携带 usage,
  80. // 但流在 message_stop 前直接结束。
  81. upstreamSSE := strings.Join([]string{
  82. `event: message_start`,
  83. `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}}}`,
  84. "",
  85. `event: content_block_delta`,
  86. `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`,
  87. "",
  88. `event: message_delta`,
  89. `data: {"type":"message_delta","delta":{"stop_reason":null},"usage":{"output_tokens":5}}`,
  90. "",
  91. "",
  92. }, "\n")
  93. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  94. StatusCode: http.StatusOK,
  95. Header: http.Header{
  96. "Content-Type": []string{"text/event-stream"},
  97. "X-Request-Id": []string{"rid-partial"},
  98. },
  99. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  100. }}
  101. svc := newForwardPartialUsageServiceForTest(upstream)
  102. account := newAnthropicOAuthAccountForPartialUsageTest()
  103. result, err := svc.Forward(context.Background(), c, account, parsed)
  104. require.Error(t, err)
  105. require.Contains(t, err.Error(), "missing terminal event")
  106. require.NotNil(t, result, "流中断但已观测到 usage 时必须返回部分结果用于计费")
  107. require.True(t, result.Stream)
  108. require.Equal(t, 11, result.Usage.InputTokens)
  109. require.Equal(t, 7, result.Usage.CacheReadInputTokens)
  110. require.Equal(t, 5, result.Usage.OutputTokens)
  111. require.Equal(t, "rid-partial", result.RequestID)
  112. require.NotNil(t, result.FirstTokenMs)
  113. }
  114. func TestGatewayService_Forward_StreamReadErrorAfterOutputPreservesPartialUsage(t *testing.T) {
  115. gin.SetMode(gin.TestMode)
  116. rec := httptest.NewRecorder()
  117. c, _ := gin.CreateTestContext(rec)
  118. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  119. body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  120. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  121. require.NoError(t, err)
  122. // message_start 已写出(含 usage),随后上游连接异常中断。
  123. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  124. StatusCode: http.StatusOK,
  125. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  126. Body: &streamReadCloser{
  127. payload: []byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":4}}}\n\n"),
  128. err: io.ErrUnexpectedEOF,
  129. },
  130. }}
  131. svc := newForwardPartialUsageServiceForTest(upstream)
  132. account := newAnthropicOAuthAccountForPartialUsageTest()
  133. result, err := svc.Forward(context.Background(), c, account, parsed)
  134. require.Error(t, err)
  135. require.Contains(t, err.Error(), "stream read error")
  136. require.NotNil(t, result, "已写出内容后的读错误必须保留部分 usage")
  137. require.Equal(t, 9, result.Usage.InputTokens)
  138. require.Equal(t, 4, result.Usage.CacheCreationInputTokens)
  139. }
  140. func TestGatewayService_Forward_StreamErrorWithoutUsageReturnsNilResult(t *testing.T) {
  141. gin.SetMode(gin.TestMode)
  142. rec := httptest.NewRecorder()
  143. c, _ := gin.CreateTestContext(rec)
  144. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  145. body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  146. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  147. require.NoError(t, err)
  148. // 只有 ping、没有任何 usage 的流中断:不应产生零 usage 的幽灵账单记录。
  149. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  150. StatusCode: http.StatusOK,
  151. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  152. Body: io.NopCloser(strings.NewReader("event: ping\ndata: {\"type\": \"ping\"}\n\n")),
  153. }}
  154. svc := newForwardPartialUsageServiceForTest(upstream)
  155. account := newAnthropicOAuthAccountForPartialUsageTest()
  156. result, err := svc.Forward(context.Background(), c, account, parsed)
  157. require.Error(t, err)
  158. require.Contains(t, err.Error(), "missing terminal event")
  159. require.Nil(t, result, "无已观测 usage 时不应返回部分结果")
  160. }
  161. func TestGatewayService_Forward_FailoverErrorKeepsNilResult(t *testing.T) {
  162. gin.SetMode(gin.TestMode)
  163. rec := httptest.NewRecorder()
  164. c, _ := gin.CreateTestContext(rec)
  165. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  166. body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  167. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  168. require.NoError(t, err)
  169. // 未向客户端写出任何字节前的读错误会包成 UpstreamFailoverError 走换号重试。
  170. // 该路径必须保持 result=nil:failover 成功后按成功请求计费,双份结果会重复计费。
  171. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  172. StatusCode: http.StatusOK,
  173. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  174. Body: &streamReadCloser{
  175. err: errors.New("connection reset by peer"),
  176. },
  177. }}
  178. svc := newForwardPartialUsageServiceForTest(upstream)
  179. account := newAnthropicOAuthAccountForPartialUsageTest()
  180. result, err := svc.Forward(context.Background(), c, account, parsed)
  181. require.Error(t, err)
  182. var failoverErr *UpstreamFailoverError
  183. require.True(t, errors.As(err, &failoverErr))
  184. require.Nil(t, result, "failover 错误必须保持 result=nil,防止重试成功后双重计费")
  185. }
  186. func TestGatewayService_Forward_PreOutputSSEOverloadedErrorUsesSemantic529(t *testing.T) {
  187. gin.SetMode(gin.TestMode)
  188. rec := httptest.NewRecorder()
  189. c, _ := gin.CreateTestContext(rec)
  190. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  191. body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`)
  192. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  193. require.NoError(t, err)
  194. const errorJSON = `{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_01"}`
  195. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  196. StatusCode: http.StatusOK,
  197. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  198. Body: io.NopCloser(strings.NewReader("event: error\ndata: " + errorJSON + "\n\n")),
  199. }}
  200. repo := &gatewayForwardErrorPolicyRepoStub{}
  201. cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
  202. svc := &GatewayService{
  203. cfg: cfg,
  204. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  205. httpUpstream: upstream,
  206. rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
  207. deferredService: &DeferredService{},
  208. }
  209. account := newAnthropicOAuthAccountForPartialUsageTest()
  210. account.Credentials["temp_unschedulable_enabled"] = true
  211. account.Credentials["temp_unschedulable_rules"] = []any{map[string]any{
  212. "error_code": float64(529),
  213. "keywords": []any{"Overloaded"},
  214. "duration_minutes": float64(10),
  215. }}
  216. result, err := svc.Forward(context.Background(), c, account, parsed)
  217. require.Error(t, err)
  218. require.Nil(t, result)
  219. var failoverErr *UpstreamFailoverError
  220. require.ErrorAs(t, err, &failoverErr)
  221. require.Equal(t, 529, failoverErr.StatusCode)
  222. require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
  223. require.Equal(t, 1, repo.overloadCalls, "synthetic 529 must apply global overload cooldown")
  224. require.Empty(t, repo.modelRateLimitCalls, "global 529 cooldown must take precedence over custom model rules")
  225. require.Empty(t, rec.Body.String(), "pre-output overload must remain eligible for account failover")
  226. }
  227. func TestGatewayService_Forward_PostOutputSSEOverloadedErrorKeepsExistingStatus(t *testing.T) {
  228. gin.SetMode(gin.TestMode)
  229. rec := httptest.NewRecorder()
  230. c, _ := gin.CreateTestContext(rec)
  231. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  232. body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":"hello"}]}`)
  233. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  234. require.NoError(t, err)
  235. const errorJSON = `{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`
  236. fixture := "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":1}}}\n\n" +
  237. "event: error\ndata: " + errorJSON + "\n\n"
  238. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  239. StatusCode: http.StatusOK,
  240. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  241. Body: io.NopCloser(strings.NewReader(fixture)),
  242. }}
  243. repo := &gatewayForwardErrorPolicyRepoStub{}
  244. cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
  245. svc := &GatewayService{
  246. cfg: cfg,
  247. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  248. httpUpstream: upstream,
  249. rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
  250. deferredService: &DeferredService{},
  251. }
  252. result, err := svc.Forward(context.Background(), c, newAnthropicOAuthAccountForPartialUsageTest(), parsed)
  253. require.Error(t, err)
  254. require.Nil(t, result)
  255. var failoverErr *UpstreamFailoverError
  256. require.ErrorAs(t, err, &failoverErr)
  257. require.Equal(t, http.StatusForbidden, failoverErr.StatusCode)
  258. require.JSONEq(t, errorJSON, string(failoverErr.ResponseBody))
  259. require.Zero(t, repo.tempCalls)
  260. require.Contains(t, rec.Body.String(), "message_start")
  261. }
  262. func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamMissingTerminalPreservesPartialUsage(t *testing.T) {
  263. gin.SetMode(gin.TestMode)
  264. rec := httptest.NewRecorder()
  265. c, _ := gin.CreateTestContext(rec)
  266. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  267. body := []byte(`{"model":"claude-3-7-sonnet-20250219","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  268. parsed := &ParsedRequest{
  269. Body: NewRequestBodyRef(body),
  270. Model: "claude-3-7-sonnet-20250219",
  271. Stream: true,
  272. }
  273. upstreamSSE := strings.Join([]string{
  274. `data: {"type":"message_start","message":{"usage":{"input_tokens":9,"cache_read_input_tokens":2}}}`,
  275. "",
  276. `data: {"type":"message_delta","usage":{"output_tokens":3}}`,
  277. "",
  278. }, "\n")
  279. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  280. StatusCode: http.StatusOK,
  281. Header: http.Header{
  282. "Content-Type": []string{"text/event-stream"},
  283. "X-Request-Id": []string{"rid-pass-partial"},
  284. },
  285. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  286. }}
  287. svc := newForwardPartialUsageServiceForTest(upstream)
  288. account := newAnthropicAPIKeyAccountForTest()
  289. result, err := svc.Forward(context.Background(), c, account, parsed)
  290. require.Error(t, err)
  291. require.Contains(t, err.Error(), "missing terminal event")
  292. require.NotNil(t, result, "透传流中断但已观测到 usage 时必须返回部分结果用于计费")
  293. require.True(t, result.Stream)
  294. require.Equal(t, 9, result.Usage.InputTokens)
  295. require.Equal(t, 2, result.Usage.CacheReadInputTokens)
  296. require.Equal(t, 3, result.Usage.OutputTokens)
  297. require.Equal(t, "claude-3-7-sonnet-20250219", result.Model)
  298. }