openai_apikey_responses_probe_verdict_test.go 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. package service
  2. import (
  3. "context"
  4. "io"
  5. "net/http"
  6. "strings"
  7. "testing"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  10. "github.com/stretchr/testify/require"
  11. )
  12. func newResponsesProbeAccount(id int64) Account {
  13. return Account{
  14. ID: id,
  15. Platform: PlatformOpenAI,
  16. Type: AccountTypeAPIKey,
  17. Name: "compat-upstream",
  18. Concurrency: 1,
  19. Credentials: map[string]any{
  20. "api_key": "sk-test",
  21. "base_url": "https://compat-upstream.example/v1",
  22. },
  23. }
  24. }
  25. // runResponsesProbe 跑一次探测,返回落库的 extra 更新;未落库时返回 nil。
  26. func runResponsesProbe(t *testing.T, status int, body string) map[string]any {
  27. t.Helper()
  28. account := newResponsesProbeAccount(4200)
  29. // 带缓冲且不阻塞:探测决定不落标时通道应保持为空。
  30. updateCalls := make(chan map[string]any, 1)
  31. repo := &snapshotUpdateAccountRepo{
  32. stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
  33. updateExtraCalls: updateCalls,
  34. }
  35. svc := &AccountTestService{
  36. accountRepo: repo,
  37. httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
  38. StatusCode: status,
  39. Header: make(http.Header),
  40. Body: io.NopCloser(strings.NewReader(body)),
  41. }},
  42. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  43. }
  44. svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
  45. select {
  46. case updates := <-updateCalls:
  47. return updates
  48. default:
  49. return nil
  50. }
  51. }
  52. // issue #5371:账号一旦被落标为「不支持 Responses」,网关就长期改走
  53. // /v1/chat/completions,Codex 的 prompt 缓存前缀被打散;而探测只在账号创建/更新时
  54. // 跑一次,标记不会自动恢复。因此判据不成立的响应绝不能落标。
  55. func TestProbeOpenAIAPIKeyResponsesSupport_InconclusiveResponseKeepsUnknown(t *testing.T) {
  56. cases := []struct {
  57. name string
  58. body string
  59. }{
  60. {
  61. // 探测请求自带 openaiResponsesProbeMaxOutputTokens 预算,推理型模型可能
  62. // 把预算烧在 reasoning 上就被截断——没有 function_call 是预算不足所致。
  63. name: "incomplete_max_output_tokens",
  64. body: `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},` +
  65. `"output":[{"type":"reasoning","summary":[]}]}`,
  66. },
  67. {
  68. // HTTP 200 携带的失败响应是上游瞬时故障,不构成工具能力证据。
  69. name: "failed_status_on_http_200",
  70. body: `{"status":"failed","error":{"code":"server_error","message":"upstream hiccup"},"output":[]}`,
  71. },
  72. }
  73. for _, tc := range cases {
  74. t.Run(tc.name, func(t *testing.T) {
  75. require.Nil(t, runResponsesProbe(t, http.StatusOK, tc.body),
  76. "判据不成立时必须保持 unknown,不得落标")
  77. })
  78. }
  79. }
  80. // 对照不变式:能下结论的响应仍要落标,否则「不落标」写宽了就等于把整个探测废掉。
  81. func TestProbeOpenAIAPIKeyResponsesSupport_ConclusiveResponsesStillPersist(t *testing.T) {
  82. cases := []struct {
  83. name string
  84. status int
  85. body string
  86. want bool
  87. }{
  88. {
  89. name: "completed_with_function_call",
  90. status: http.StatusOK,
  91. body: `{"status":"completed","output":[{"type":"function_call","name":"probe_ping"}]}`,
  92. want: true,
  93. },
  94. {
  95. // 火山方舟 coding/v3 × kimi-k2.6:端点在、跑完了、就是不产出 function_call。
  96. // 这正是探测要抓的目标,必须继续落标为不支持。
  97. name: "completed_reasoning_only",
  98. status: http.StatusOK,
  99. body: `{"status":"completed","output":[{"type":"reasoning"}]}`,
  100. want: false,
  101. },
  102. {
  103. // 非 max_output_tokens 的截断(如内容过滤)不在放行范围内,维持原判定。
  104. name: "incomplete_other_reason",
  105. status: http.StatusOK,
  106. body: `{"status":"incomplete","incomplete_details":{"reason":"content_filter"},"output":[]}`,
  107. want: false,
  108. },
  109. {
  110. // 响应体没有 status 字段(第三方兼容上游常见)时维持既有行为。
  111. name: "no_status_field",
  112. status: http.StatusOK,
  113. body: `{"output":[{"type":"function_call","name":"probe_ping"}]}`,
  114. want: true,
  115. },
  116. {
  117. name: "endpoint_absent_404",
  118. status: http.StatusNotFound,
  119. body: `{"error":{"message":"Not Found"}}`,
  120. want: false,
  121. },
  122. {
  123. // 非 2xx 的结论只看状态码:body 里的 status=failed 不该让它变成"不下结论"。
  124. name: "server_error_stays_conservative_true",
  125. status: http.StatusInternalServerError,
  126. body: `{"status":"failed"}`,
  127. want: true,
  128. },
  129. }
  130. for _, tc := range cases {
  131. t.Run(tc.name, func(t *testing.T) {
  132. updates := runResponsesProbe(t, tc.status, tc.body)
  133. require.NotNil(t, updates, "能下结论的响应必须落标")
  134. require.Equal(t, tc.want, updates[openai_compat.ExtraKeyResponsesSupported])
  135. })
  136. }
  137. }
  138. func TestResponsesProbeVerdictIsConclusive(t *testing.T) {
  139. cases := []struct {
  140. name string
  141. status int
  142. body string
  143. want bool
  144. }{
  145. {"200_completed", 200, `{"status":"completed","output":[]}`, true},
  146. {"200_incomplete_max_output_tokens", 200,
  147. `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}`, false},
  148. {"200_incomplete_content_filter", 200,
  149. `{"status":"incomplete","incomplete_details":{"reason":"content_filter"}}`, true},
  150. {"200_incomplete_without_reason", 200, `{"status":"incomplete"}`, true},
  151. {"200_failed", 200, `{"status":"failed"}`, false},
  152. {"200_no_status_field", 200, `{"output":[]}`, true},
  153. {"200_non_json", 200, `not-json`, true},
  154. {"200_empty_body", 200, ``, true},
  155. // 非 2xx 只看状态码,不读 body。
  156. {"404_ignores_body_status", 404, `{"status":"failed"}`, true},
  157. {"500_ignores_body_status", 500, `{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}`, true},
  158. }
  159. for _, tc := range cases {
  160. t.Run(tc.name, func(t *testing.T) {
  161. require.Equal(t, tc.want, responsesProbeVerdictIsConclusive(tc.status, []byte(tc.body)))
  162. })
  163. }
  164. }