openai_gateway_messages_anthropic_native_test.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "testing"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. "github.com/tidwall/gjson"
  13. )
  14. // 国产供应商原生 Anthropic 直通路径(api_protocol=anthropic)的 reasoning_effort 记录。
  15. // 回归背景:该路径此前从不提取 Claude 协议的 output_config.effort,也不做
  16. // thinking-enabled 兜底,导致 kimi/zhipu/deepseek 平台分组的 /v1/messages 请求
  17. // usage_log.reasoning_effort 恒为 NULL。
  18. func nativeAnthropicTestAccount() *Account {
  19. return &Account{
  20. ID: 702,
  21. Name: "kimi-native",
  22. Platform: PlatformKimi,
  23. Type: AccountTypeAPIKey,
  24. Concurrency: 1,
  25. Credentials: map[string]any{
  26. "api_key": "sk-test",
  27. "api_protocol": APIProtocolAnthropic,
  28. "api_base_urls": map[string]any{
  29. APIProtocolAnthropic: "http://anthropic.example",
  30. },
  31. },
  32. }
  33. }
  34. func nativeAnthropicGLMTestAccount() *Account {
  35. account := nativeAnthropicTestAccount()
  36. account.Name = "zhipu-native"
  37. account.Platform = PlatformZhipu
  38. return account
  39. }
  40. func nativeAnthropicBufferedResponse() *http.Response {
  41. return &http.Response{
  42. StatusCode: http.StatusOK,
  43. Header: http.Header{"Content-Type": []string{"application/json"}},
  44. Body: io.NopCloser(strings.NewReader(
  45. `{"id":"msg_1","type":"message","role":"assistant","model":"k3",` +
  46. `"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn",` +
  47. `"usage":{"input_tokens":93,"output_tokens":16}}`,
  48. )),
  49. }
  50. }
  51. func nativeAnthropicStreamResponse() *http.Response {
  52. sse := `event: message_start
  53. data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"k3","content":[],"stop_reason":null,"usage":{"input_tokens":93,"output_tokens":1}}}
  54. event: content_block_start
  55. data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
  56. event: content_block_delta
  57. data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"pong"}}
  58. event: content_block_stop
  59. data: {"type":"content_block_stop","index":0}
  60. event: message_delta
  61. data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":16}}
  62. event: message_stop
  63. data: {"type":"message_stop"}
  64. `
  65. return &http.Response{
  66. StatusCode: http.StatusOK,
  67. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  68. Body: io.NopCloser(strings.NewReader(sse)),
  69. }
  70. }
  71. func TestNativeAnthropicPassthroughRecordsOutputConfigEffort(t *testing.T) {
  72. gin.SetMode(gin.TestMode)
  73. body := []byte(`{"model":"k3","max_tokens":32,"stream":false,` +
  74. `"output_config":{"effort":"low"},` +
  75. `"messages":[{"role":"user","content":"hi"}]}`)
  76. upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()}
  77. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  78. result, err := svc.ForwardAsAnthropic(context.Background(),
  79. adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "")
  80. require.NoError(t, err)
  81. require.NotNil(t, result)
  82. require.NotNil(t, result.ReasoningEffort)
  83. require.Equal(t, "low", *result.ReasoningEffort)
  84. }
  85. func TestNativeAnthropicPassthroughThinkingEnabledFallback(t *testing.T) {
  86. gin.SetMode(gin.TestMode)
  87. // 未显式传 effort,但 thinking 已启用:k3 属于 passback-required 白名单,应兜底记为 high。
  88. body := []byte(`{"model":"k3","max_tokens":32,"stream":false,` +
  89. `"thinking":{"type":"enabled","budget_tokens":1024},` +
  90. `"messages":[{"role":"user","content":"hi"}]}`)
  91. upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()}
  92. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  93. result, err := svc.ForwardAsAnthropic(context.Background(),
  94. adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "")
  95. require.NoError(t, err)
  96. require.NotNil(t, result)
  97. require.NotNil(t, result.ReasoningEffort)
  98. require.Equal(t, "high", *result.ReasoningEffort)
  99. }
  100. func TestNativeAnthropicPassthroughStreamRecordsEffort(t *testing.T) {
  101. gin.SetMode(gin.TestMode)
  102. body := []byte(`{"model":"k3","max_tokens":32,"stream":true,` +
  103. `"output_config":{"effort":"max"},` +
  104. `"messages":[{"role":"user","content":"hi"}]}`)
  105. upstream := &httpUpstreamRecorder{resp: nativeAnthropicStreamResponse()}
  106. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  107. result, err := svc.ForwardAsAnthropic(context.Background(),
  108. adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "")
  109. require.NoError(t, err)
  110. require.NotNil(t, result)
  111. require.NotNil(t, result.ReasoningEffort)
  112. require.Equal(t, "max", *result.ReasoningEffort)
  113. }
  114. func TestNativeAnthropicPassthroughNoEffortStaysNil(t *testing.T) {
  115. gin.SetMode(gin.TestMode)
  116. // 既无 output_config.effort 也未启用 thinking:保持 nil,不做语义注入。
  117. body := []byte(`{"model":"k3","max_tokens":32,"stream":false,` +
  118. `"messages":[{"role":"user","content":"hi"}]}`)
  119. upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()}
  120. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  121. result, err := svc.ForwardAsAnthropic(context.Background(),
  122. adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicTestAccount(), body, "", "")
  123. require.NoError(t, err)
  124. require.NotNil(t, result)
  125. require.Nil(t, result.ReasoningEffort)
  126. }
  127. func TestNativeAnthropicPassthroughNormalizesGLM53Thinking(t *testing.T) {
  128. gin.SetMode(gin.TestMode)
  129. tests := []struct {
  130. name string
  131. stream bool
  132. preference string
  133. wantEffort string
  134. }{
  135. {name: "disabled buffered", preference: `"thinking":{"type":"disabled"},`, wantEffort: "low"},
  136. {name: "off buffered", preference: `"thinking":{"type":"off"},`, wantEffort: "low"},
  137. {name: "none buffered", preference: `"thinking":{"type":"none"},`, wantEffort: "low"},
  138. {name: "enabled buffered", preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"},
  139. {name: "enabled streaming", stream: true, preference: `"thinking":{"type":"enabled"},`, wantEffort: "high"},
  140. {name: "adaptive buffered", preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"},
  141. {name: "adaptive streaming", stream: true, preference: `"thinking":{"type":"adaptive"},`, wantEffort: "high"},
  142. {name: "minimal buffered", preference: `"output_config":{"effort":"minimal"},`, wantEffort: "low"},
  143. {name: "low buffered", preference: `"output_config":{"effort":"low"},`, wantEffort: "low"},
  144. {name: "medium streaming", stream: true, preference: `"output_config":{"effort":"medium"},`, wantEffort: "high"},
  145. {name: "high buffered", preference: `"output_config":{"effort":"high"},`, wantEffort: "high"},
  146. {name: "xhigh buffered", preference: `"output_config":{"effort":"xhigh"},`, wantEffort: "max"},
  147. {name: "max buffered", preference: `"output_config":{"effort":"max"},`, wantEffort: "max"},
  148. {name: "ultra buffered", preference: `"output_config":{"effort":"ultra"},`, wantEffort: "max"},
  149. {name: "output effort wins over thinking", preference: `"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},`, wantEffort: "low"},
  150. }
  151. for _, tt := range tests {
  152. t.Run(tt.name, func(t *testing.T) {
  153. body := []byte(fmt.Sprintf(`{"model":"glm-5.3","max_tokens":32,"stream":%t,%s"messages":[{"role":"user","content":"hi"}]}`, tt.stream, tt.preference))
  154. response := nativeAnthropicBufferedResponse()
  155. if tt.stream {
  156. response = nativeAnthropicStreamResponse()
  157. }
  158. upstream := &httpUpstreamRecorder{resp: response}
  159. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  160. _, err := svc.ForwardAsAnthropic(context.Background(),
  161. adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "")
  162. require.NoError(t, err)
  163. require.Equal(t, "enabled", gjson.GetBytes(upstream.lastBody, "thinking.type").String())
  164. require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "output_config.effort").String())
  165. })
  166. }
  167. }
  168. func TestNativeAnthropicPassthroughLeavesOtherThinkingUntouched(t *testing.T) {
  169. gin.SetMode(gin.TestMode)
  170. tests := []struct {
  171. name string
  172. body string
  173. }{
  174. {name: "glm 5.3 unspecified", body: `{"model":"glm-5.3","max_tokens":32,"stream":false,"messages":[]}`},
  175. {name: "glm 5.2 disabled", body: `{"model":"glm-5.2","max_tokens":32,"stream":false,"thinking":{"type":"disabled"},"messages":[]}`},
  176. }
  177. for _, tt := range tests {
  178. t.Run(tt.name, func(t *testing.T) {
  179. body := []byte(tt.body)
  180. upstream := &httpUpstreamRecorder{resp: nativeAnthropicBufferedResponse()}
  181. svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
  182. _, err := svc.ForwardAsAnthropic(context.Background(),
  183. adaptiveProtocolTestContext("/v1/messages", body), nativeAnthropicGLMTestAccount(), body, "", "")
  184. require.NoError(t, err)
  185. require.JSONEq(t, tt.body, string(upstream.lastBody))
  186. })
  187. }
  188. }