openai_gateway_anthropic_native_pump_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. package service
  2. // 国产供应商 Anthropic 协议转换路径的上游读间隔超时回归测试(B3):
  3. // 上游挂住 SSE(不发数据也不断连)时,CC×anthropic / Responses×anthropic
  4. // 的读循环必须按 gateway.stream_data_interval_timeout 结束,而不是永久阻塞。
  5. import (
  6. "bufio"
  7. "encoding/json"
  8. "io"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  16. "github.com/gin-gonic/gin"
  17. )
  18. func newNativeAnthropicHangTestService(intervalSec int) *OpenAIGatewayService {
  19. return &OpenAIGatewayService{
  20. cfg: &config.Config{
  21. Gateway: config.GatewayConfig{
  22. StreamDataIntervalTimeout: intervalSec,
  23. MaxLineSize: defaultMaxLineSize,
  24. },
  25. },
  26. }
  27. }
  28. func newHangingUpstreamResponse() (*http.Response, *io.PipeReader, *io.PipeWriter) {
  29. pr, pw := io.Pipe()
  30. return &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}, pr, pw
  31. }
  32. // miniAnthropicSSEStream 是一段最小可转换的 Anthropic 事件流。
  33. func miniAnthropicSSEStream() string {
  34. return strings.Join([]string{
  35. "event: message_start",
  36. `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":1}}}`,
  37. "",
  38. "event: content_block_start",
  39. `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
  40. "",
  41. "event: content_block_delta",
  42. `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`,
  43. "",
  44. "event: content_block_stop",
  45. `data: {"type":"content_block_stop","index":0}`,
  46. "",
  47. "event: message_delta",
  48. `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
  49. "",
  50. "event: message_stop",
  51. `data: {"type":"message_stop"}`,
  52. "",
  53. "",
  54. }, "\n")
  55. }
  56. func toolAnthropicSSEStream() string {
  57. return strings.Join([]string{
  58. "event: message_start",
  59. `data: {"type":"message_start","message":{"id":"msg_tool","type":"message","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10}}}`,
  60. "",
  61. "event: content_block_start",
  62. `data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}}`,
  63. "",
  64. "event: content_block_delta",
  65. `data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"query\":\"status\"}"}}`,
  66. "",
  67. "event: content_block_stop",
  68. `data: {"type":"content_block_stop","index":0}`,
  69. "",
  70. "event: message_delta",
  71. `data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":5}}`,
  72. "",
  73. "event: message_stop",
  74. `data: {"type":"message_stop"}`,
  75. "",
  76. }, "\n")
  77. }
  78. func TestAnthropicNativeLinePump_TimesOutWithoutData(t *testing.T) {
  79. pr, _ := io.Pipe()
  80. scanner := bufio.NewScanner(pr)
  81. defer func() { _ = pr.Close() }()
  82. pump := newAnthropicNativeLinePump(scanner, 50*time.Millisecond)
  83. defer pump.stop()
  84. start := time.Now()
  85. _, err := pump.next()
  86. if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
  87. t.Fatalf("expected interval timeout, got %v", err)
  88. }
  89. if elapsed := time.Since(start); elapsed > 5*time.Second {
  90. t.Fatalf("timeout not respected: %v", elapsed)
  91. }
  92. }
  93. func TestAnthropicNativeLinePump_DataResetsTimer(t *testing.T) {
  94. pr, pw := io.Pipe()
  95. scanner := bufio.NewScanner(pr)
  96. pump := newAnthropicNativeLinePump(scanner, 1*time.Second)
  97. defer pump.stop()
  98. go func() {
  99. _, _ = pw.Write([]byte("event: ping\n"))
  100. // 保持流打开且不再发数据:第二次 next 必须超时。
  101. time.Sleep(3 * time.Second)
  102. _ = pw.Close()
  103. }()
  104. defer func() { _ = pr.Close() }()
  105. line, err := pump.next()
  106. if err != nil || line != "event: ping" {
  107. t.Fatalf("expected first line, got %q err=%v", line, err)
  108. }
  109. start := time.Now()
  110. _, err = pump.next()
  111. if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
  112. t.Fatalf("expected interval timeout after data stops, got %v", err)
  113. }
  114. if elapsed := time.Since(start); elapsed > 5*time.Second {
  115. t.Fatalf("timeout not respected: %v", elapsed)
  116. }
  117. }
  118. func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) {
  119. gin.SetMode(gin.TestMode)
  120. svc := newNativeAnthropicHangTestService(1)
  121. rec := httptest.NewRecorder()
  122. c, _ := gin.CreateTestContext(rec)
  123. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  124. resp, pr, pw := newHangingUpstreamResponse()
  125. start := time.Now()
  126. res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true)
  127. _ = pw.Close()
  128. _ = pr.Close()
  129. if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
  130. t.Fatalf("expected stream timeout error, got %v", err)
  131. }
  132. if res == nil {
  133. t.Fatalf("expected result carrying accumulated usage")
  134. }
  135. if elapsed := time.Since(start); elapsed > 5*time.Second {
  136. t.Fatalf("handler did not respect interval bound: %v", elapsed)
  137. }
  138. }
  139. func TestCCBufferedFromNativeAnthropic_HangTimesOut(t *testing.T) {
  140. gin.SetMode(gin.TestMode)
  141. svc := newNativeAnthropicHangTestService(1)
  142. rec := httptest.NewRecorder()
  143. c, _ := gin.CreateTestContext(rec)
  144. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  145. resp, pr, pw := newHangingUpstreamResponse()
  146. start := time.Now()
  147. _, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start)
  148. _ = pw.Close()
  149. _ = pr.Close()
  150. if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
  151. t.Fatalf("expected stream timeout error, got %v", err)
  152. }
  153. if elapsed := time.Since(start); elapsed > 5*time.Second {
  154. t.Fatalf("handler did not respect interval bound: %v", elapsed)
  155. }
  156. if !strings.Contains(rec.Body.String(), "Upstream stream data interval timeout") {
  157. t.Fatalf("expected 502 error body, got %q", rec.Body.String())
  158. }
  159. }
  160. func TestResponsesStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) {
  161. gin.SetMode(gin.TestMode)
  162. svc := newNativeAnthropicHangTestService(1)
  163. rec := httptest.NewRecorder()
  164. c, _ := gin.CreateTestContext(rec)
  165. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  166. resp, pr, pw := newHangingUpstreamResponse()
  167. start := time.Now()
  168. res, err := svc.handleResponsesStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, apicompat.ResponsesClientToolMapping{})
  169. _ = pw.Close()
  170. _ = pr.Close()
  171. if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
  172. t.Fatalf("expected stream timeout error, got %v", err)
  173. }
  174. if res == nil {
  175. t.Fatalf("expected result carrying accumulated usage")
  176. }
  177. if elapsed := time.Since(start); elapsed > 5*time.Second {
  178. t.Fatalf("handler did not respect interval bound: %v", elapsed)
  179. }
  180. }
  181. func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) {
  182. gin.SetMode(gin.TestMode)
  183. svc := newNativeAnthropicHangTestService(5)
  184. rec := httptest.NewRecorder()
  185. c, _ := gin.CreateTestContext(rec)
  186. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  187. resp, pr, pw := newHangingUpstreamResponse()
  188. go func() {
  189. _, _ = pw.Write([]byte(miniAnthropicSSEStream()))
  190. _ = pw.Close()
  191. }()
  192. defer func() { _ = pr.Close() }()
  193. res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true)
  194. if err != nil {
  195. t.Fatalf("unexpected error: %v", err)
  196. }
  197. if res == nil {
  198. t.Fatalf("expected result")
  199. }
  200. body := rec.Body.String()
  201. if !strings.Contains(body, "Hello") {
  202. t.Fatalf("expected converted text chunk, got %q", body)
  203. }
  204. if !strings.Contains(body, "data: [DONE]") {
  205. t.Fatalf("expected [DONE] terminator, got %q", body)
  206. }
  207. }
  208. func TestCCBufferedFromNativeAnthropic_HappyPathStillConverts(t *testing.T) {
  209. gin.SetMode(gin.TestMode)
  210. svc := newNativeAnthropicHangTestService(5)
  211. rec := httptest.NewRecorder()
  212. c, _ := gin.CreateTestContext(rec)
  213. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  214. resp, pr, pw := newHangingUpstreamResponse()
  215. go func() {
  216. _, _ = pw.Write([]byte(miniAnthropicSSEStream()))
  217. _ = pw.Close()
  218. }()
  219. defer func() { _ = pr.Close() }()
  220. res, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now())
  221. if err != nil {
  222. t.Fatalf("unexpected error: %v", err)
  223. }
  224. if res == nil {
  225. t.Fatalf("expected result")
  226. }
  227. body := rec.Body.String()
  228. if !strings.Contains(body, "Hello") {
  229. t.Fatalf("expected converted text in buffered response, got %q", body)
  230. }
  231. if res.Usage.InputTokens != 10 || res.Usage.OutputTokens != 5 {
  232. t.Fatalf("expected usage 10/5, got %+v", res.Usage)
  233. }
  234. }
  235. func TestCCBufferedFromNativeAnthropic_ToolArgumentsAreValidJSON(t *testing.T) {
  236. gin.SetMode(gin.TestMode)
  237. svc := newNativeAnthropicHangTestService(5)
  238. rec := httptest.NewRecorder()
  239. c, _ := gin.CreateTestContext(rec)
  240. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  241. resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(toolAnthropicSSEStream())), Header: http.Header{}}
  242. _, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now())
  243. if err != nil {
  244. t.Fatalf("unexpected error: %v", err)
  245. }
  246. var body struct {
  247. Choices []struct {
  248. Message struct {
  249. ToolCalls []struct {
  250. Function struct {
  251. Arguments string `json:"arguments"`
  252. } `json:"function"`
  253. } `json:"tool_calls"`
  254. } `json:"message"`
  255. } `json:"choices"`
  256. }
  257. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  258. t.Fatalf("decode response: %v", err)
  259. }
  260. if len(body.Choices) != 1 || len(body.Choices[0].Message.ToolCalls) != 1 {
  261. t.Fatalf("expected one tool call, got %s", rec.Body.String())
  262. }
  263. args := body.Choices[0].Message.ToolCalls[0].Function.Arguments
  264. if !json.Valid([]byte(args)) || args != `{"query":"status"}` {
  265. t.Fatalf("expected valid tool arguments, got %q", args)
  266. }
  267. }
  268. func TestResponsesBufferedFromNativeAnthropic_ToolArgumentsAreValidJSON(t *testing.T) {
  269. gin.SetMode(gin.TestMode)
  270. svc := newNativeAnthropicHangTestService(5)
  271. rec := httptest.NewRecorder()
  272. c, _ := gin.CreateTestContext(rec)
  273. c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
  274. resp := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(toolAnthropicSSEStream())), Header: http.Header{}}
  275. _, err := svc.handleResponsesBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), apicompat.ResponsesClientToolMapping{})
  276. if err != nil {
  277. t.Fatalf("unexpected error: %v", err)
  278. }
  279. var body struct {
  280. Output []struct {
  281. Type string `json:"type"`
  282. Arguments string `json:"arguments"`
  283. } `json:"output"`
  284. }
  285. if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
  286. t.Fatalf("decode response: %v", err)
  287. }
  288. if len(body.Output) != 1 || body.Output[0].Type != "function_call" {
  289. t.Fatalf("expected one function call, got %s", rec.Body.String())
  290. }
  291. if args := body.Output[0].Arguments; !json.Valid([]byte(args)) || args != `{"query":"status"}` {
  292. t.Fatalf("expected valid tool arguments, got %q", args)
  293. }
  294. }