no_account_error_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. //go:build unit
  2. package handler
  3. import (
  4. "context"
  5. "fmt"
  6. "net/http"
  7. "net/http/httptest"
  8. "testing"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/require"
  11. "github.com/Wei-Shaw/sub2api/internal/service"
  12. )
  13. type fakeDiagnoser struct {
  14. calls []fakeDiagnoseCall
  15. resp service.ModelAvailabilityDiagnosis
  16. }
  17. type fakeDiagnoseCall struct {
  18. GroupID *int64
  19. Model string
  20. Platform string
  21. }
  22. func (f *fakeDiagnoser) DiagnoseModelAvailabilityForPlatform(
  23. _ context.Context,
  24. groupID *int64,
  25. model, platform string,
  26. ) service.ModelAvailabilityDiagnosis {
  27. f.calls = append(f.calls, fakeDiagnoseCall{
  28. GroupID: groupID,
  29. Model: model,
  30. Platform: platform,
  31. })
  32. return f.resp
  33. }
  34. func ptrInt64(v int64) *int64 { return &v }
  35. // newTestGinContextWithRequest wraps the bare newTestGinContext helper
  36. // (defined in openai_gateway_cyber_test.go) by additionally attaching a stub
  37. // *http.Request so the classifier can extract c.Request.Context().
  38. func newTestGinContextWithRequest() *gin.Context {
  39. c := newTestGinContext()
  40. c.Request = httptest.NewRequest(http.MethodPost, "/test", nil)
  41. return c
  42. }
  43. func TestClassifyNoAccountError_NilDiagnoser_Falls503(t *testing.T) {
  44. c := newTestGinContextWithRequest()
  45. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  46. cls := classifyNoAccountErrorFromGin(c, nil, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
  47. require.Equal(t, http.StatusServiceUnavailable, cls.Status)
  48. require.Equal(t, "api_error", cls.ErrType)
  49. require.False(t, cls.ModelNotFound)
  50. }
  51. func TestClassifySelectionFailureError_RateLimitedPool(t *testing.T) {
  52. fallback := noAccountErrorClassification{Status: http.StatusServiceUnavailable, ErrType: "api_error", Message: "Service temporarily unavailable"}
  53. got := classifySelectionFailureError(
  54. fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
  55. fallback,
  56. )
  57. require.Equal(t, http.StatusTooManyRequests, got.Status)
  58. require.Equal(t, "rate_limit_error", got.ErrType)
  59. require.Contains(t, got.Message, "rate-limited")
  60. require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("model_rate_limited=0"), fallback))
  61. require.Equal(t, fallback, classifySelectionFailureError(fmt.Errorf("no available accounts"), fallback))
  62. }
  63. func TestClassifyNoAccountError_NilAPIKey_Falls503(t *testing.T) {
  64. c := newTestGinContextWithRequest()
  65. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  66. cls := classifyNoAccountErrorFromGin(c, fd, nil, "gpt-5", "gpt-5", service.PlatformOpenAI)
  67. require.Equal(t, http.StatusServiceUnavailable, cls.Status)
  68. require.False(t, cls.ModelNotFound)
  69. require.Empty(t, fd.calls, "diagnoser must not be consulted when apiKey missing")
  70. }
  71. func TestClassifyNoAccountError_NilGroupID_Falls503(t *testing.T) {
  72. c := newTestGinContextWithRequest()
  73. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  74. apiKey := &service.APIKey{GroupID: nil}
  75. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
  76. require.Equal(t, http.StatusServiceUnavailable, cls.Status)
  77. require.False(t, cls.ModelNotFound)
  78. require.Empty(t, fd.calls, "diagnoser must not be consulted when group not bound")
  79. }
  80. func TestClassifyNoAccountError_EmptyModel_Falls503(t *testing.T) {
  81. c := newTestGinContextWithRequest()
  82. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  83. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  84. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, " ", "", service.PlatformOpenAI)
  85. require.Equal(t, http.StatusServiceUnavailable, cls.Status)
  86. require.False(t, cls.ModelNotFound)
  87. require.Empty(t, fd.calls)
  88. }
  89. func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
  90. c := newTestGinContextWithRequest()
  91. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  92. apiKey := &service.APIKey{GroupID: ptrInt64(42)}
  93. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.1-codex-mini", "gpt-5.1-codex-mini", service.PlatformOpenAI)
  94. require.Equal(t, http.StatusNotFound, cls.Status)
  95. require.Equal(t, "model_not_found", cls.ErrType)
  96. require.True(t, cls.ModelNotFound)
  97. require.Contains(t, cls.Message, "gpt-5.1-codex-mini", "message must surface the requested model")
  98. require.Len(t, fd.calls, 1)
  99. require.Equal(t, "gpt-5.1-codex-mini", fd.calls[0].Model)
  100. require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
  101. require.NotNil(t, fd.calls[0].GroupID)
  102. require.Equal(t, int64(42), *fd.calls[0].GroupID)
  103. require.True(t, service.HasOpsClientBusinessLimited(c))
  104. require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
  105. }
  106. func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) {
  107. c := newTestGinContextWithRequest()
  108. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  109. groupID := int64(43)
  110. apiKey := &service.APIKey{
  111. GroupID: &groupID,
  112. Group: &service.Group{
  113. ID: groupID,
  114. Platform: service.PlatformGrok,
  115. },
  116. }
  117. cls := classifyOpenAICompatibleNoAccountErrorFromGin(c, fd, apiKey, "grok-4.5", "grok-4.5")
  118. require.Equal(t, http.StatusNotFound, cls.Status)
  119. require.Equal(t, "model_not_found", cls.ErrType)
  120. require.True(t, cls.ModelNotFound)
  121. require.Len(t, fd.calls, 1)
  122. require.Equal(t, service.PlatformGrok, fd.calls[0].Platform)
  123. require.True(t, service.HasOpsClientBusinessLimited(c))
  124. require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
  125. logErr := openAICompatibleSelectionErrorForLog(
  126. fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"),
  127. service.PlatformGrok,
  128. )
  129. require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5")
  130. }
  131. func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) {
  132. c := newTestGinContextWithRequest()
  133. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  134. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  135. cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
  136. require.True(t, cls.ModelNotFound)
  137. require.False(t, service.HasOpsClientBusinessLimited(c))
  138. require.Empty(t, service.OpsClientBusinessLimitedReason(c))
  139. }
  140. func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
  141. c := newTestGinContextWithRequest()
  142. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
  143. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  144. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
  145. require.Equal(t, http.StatusServiceUnavailable, cls.Status, "model exists somewhere — caller stays on 503")
  146. require.Equal(t, "api_error", cls.ErrType)
  147. require.False(t, cls.ModelNotFound)
  148. }
  149. func TestClassifyNoAccountError_ModelSupportedOnlyByRateLimitedAccount_Returns503(t *testing.T) {
  150. c := newTestGinContextWithRequest()
  151. // The diagnoser's configured-state lookup still sees the model-supporting
  152. // account even though normal scheduling has excluded it during cooldown.
  153. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
  154. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  155. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "claude-opus-4-8", "claude-opus-4-8", service.PlatformAnthropic)
  156. require.Equal(t, http.StatusServiceUnavailable, cls.Status)
  157. require.Equal(t, "api_error", cls.ErrType)
  158. require.False(t, cls.ModelNotFound, "temporary account cooldown must remain retryable")
  159. }
  160. func TestClassifyNoAccountError_NoAccountsInPool_Stays503(t *testing.T) {
  161. c := newTestGinContextWithRequest()
  162. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: false, HasModelSupport: false}}
  163. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  164. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
  165. require.Equal(t, http.StatusServiceUnavailable, cls.Status, "empty pool is a service-availability issue, not a model issue")
  166. require.False(t, cls.ModelNotFound)
  167. }
  168. func TestClassifyNoAccountError_DisplayModelOverridesRoutingForMessage(t *testing.T) {
  169. c := newTestGinContextWithRequest()
  170. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  171. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  172. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5", "claude-3-fancy", service.PlatformOpenAI)
  173. require.True(t, cls.ModelNotFound)
  174. require.Contains(t, cls.Message, "claude-3-fancy", "user-facing message must reference the model the user asked for, not the post-mapping routing model")
  175. require.Len(t, fd.calls, 1)
  176. require.Equal(t, "gpt-5", fd.calls[0].Model, "diagnosis must run against the routing model (post group dispatch mapping)")
  177. }
  178. func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
  179. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  180. apiKey := &service.APIKey{GroupID: ptrInt64(7)}
  181. cls := classifyNoAccountErrorFromGin(nil, fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
  182. require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
  183. require.True(t, cls.ModelNotFound)
  184. require.False(t, service.HasOpsClientBusinessLimited(nil))
  185. require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
  186. }
  187. // 权威的 404 model_not_found 不能被"账号被限流"的 429 盖掉。
  188. //
  189. // 选号失败的错误串同时携带多种过滤原因,例如
  190. // "pool=9, filtered: model_not_supported=8 model_rate_limited=1":8 个账号根本不支持该模型,
  191. // 剩下 1 个恰好处于模型级冷却。此时 classifyNoAccountError 已通过持久化判据确认整个分组
  192. // 没有账号能服务该模型(ModelNotFound=true),改判成 429 "All available accounts are
  193. // currently rate-limited" 是错误诊断——重试永远不会成功,而把 429 当限流的客户端会反复
  194. // 重试并吞掉 body(Codex 只显示 "exceeded retry limit"),恰好丢掉唯一说明真实原因的信息。
  195. func TestClassifySelectionFailureError_ModelNotFoundIsNotOverriddenByRateLimited(t *testing.T) {
  196. modelNotFound := noAccountErrorClassification{
  197. Status: http.StatusNotFound,
  198. ErrType: "model_not_found",
  199. Message: `Model "gpt-5.3-codex" is not supported by any configured account in this group`,
  200. ModelNotFound: true,
  201. }
  202. got := classifySelectionFailureError(
  203. fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
  204. "(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
  205. modelNotFound,
  206. )
  207. require.Equal(t, modelNotFound, got,
  208. "分组里没有任何账号能服务该模型时,模型级冷却不该把 404 改判成 429")
  209. }
  210. // 真实调用点的顺序:先 classifyNoAccountErrorFromGin,再 classifySelectionFailureError。
  211. // 覆盖这条链路是为了同时锁住 ops 归因——调用点用 ModelNotFound 决定是否标记
  212. // routing capacity limited,一旦 404 被改判成 429,同一个请求会既被标成
  213. // local model configuration 又被标成容量问题,自相矛盾。
  214. func TestClassifySelectionFailureError_CallSiteChainKeepsModelNotFoundAttribution(t *testing.T) {
  215. c := newTestGinContextWithRequest()
  216. fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
  217. apiKey := &service.APIKey{GroupID: ptrInt64(43)}
  218. cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.3-codex", "gpt-5.3-codex", service.PlatformOpenAI)
  219. cls = classifySelectionFailureError(
  220. fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
  221. "(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
  222. cls,
  223. )
  224. require.Equal(t, http.StatusNotFound, cls.Status)
  225. require.Equal(t, "model_not_found", cls.ErrType)
  226. require.True(t, cls.ModelNotFound)
  227. require.Contains(t, cls.Message, "gpt-5.3-codex")
  228. require.True(t, service.HasOpsClientBusinessLimited(c))
  229. require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
  230. }
  231. // 池子里确实存在能服务该模型、只是全部在冷却的账号时,429 改判仍需保留:
  232. // 这种情况 fallback 是 503(HasModelSupport=true),重试是有意义的。
  233. func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *testing.T) {
  234. fallback := noAccountErrorClassification{
  235. Status: http.StatusServiceUnavailable,
  236. ErrType: "api_error",
  237. Message: "Service temporarily unavailable",
  238. }
  239. got := classifySelectionFailureError(
  240. fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
  241. fallback,
  242. )
  243. require.Equal(t, http.StatusTooManyRequests, got.Status)
  244. require.Equal(t, "rate_limit_error", got.ErrType)
  245. require.False(t, got.ModelNotFound)
  246. }