ops_error_logger_test.go 70 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998
  1. package handler
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "strings"
  7. "sync"
  8. "testing"
  9. "unicode/utf8"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  11. middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
  12. "github.com/Wei-Shaw/sub2api/internal/service"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. )
  16. type ingressRejectSettingRepo struct {
  17. service.SettingRepository
  18. getValueCalls int
  19. }
  20. func (r *ingressRejectSettingRepo) GetValue(context.Context, string) (string, error) {
  21. r.getValueCalls++
  22. return "", service.ErrSettingNotFound
  23. }
  24. func (r *ingressRejectSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
  25. r.getValueCalls++
  26. return map[string]string{}, nil
  27. }
  28. func (r *ingressRejectSettingRepo) Set(context.Context, string, string) error {
  29. return nil
  30. }
  31. type ingressRejectOpsRepo struct {
  32. service.OpsRepository
  33. insertCalls int
  34. entries []*service.OpsInsertErrorLogInput
  35. }
  36. func (r *ingressRejectOpsRepo) InsertErrorLog(_ context.Context, entry *service.OpsInsertErrorLogInput) (int64, error) {
  37. r.insertCalls++
  38. r.entries = append(r.entries, entry)
  39. return 0, nil
  40. }
  41. func (r *ingressRejectOpsRepo) BatchInsertErrorLogs(_ context.Context, entries []*service.OpsInsertErrorLogInput) (int64, error) {
  42. r.insertCalls++
  43. r.entries = append(r.entries, entries...)
  44. return 0, nil
  45. }
  46. func TestOpsErrorLogQueueByteBudget(t *testing.T) {
  47. previousBytes := opsErrorLogQueueBytes.Load()
  48. previousLen := opsErrorLogQueueLen.Load()
  49. opsErrorLogQueueBytes.Store(0)
  50. opsErrorLogQueueLen.Store(0)
  51. t.Cleanup(func() {
  52. opsErrorLogQueueBytes.Store(previousBytes)
  53. opsErrorLogQueueLen.Store(previousLen)
  54. })
  55. if !reserveOpsErrorLogQueueBytes(opsErrorLogMaxQueueBytes - 1) {
  56. t.Fatal("first reservation within byte budget should succeed")
  57. }
  58. if reserveOpsErrorLogQueueBytes(2) {
  59. t.Fatal("reservation beyond byte budget should be rejected")
  60. }
  61. if got := OpsErrorLogQueueBytes(); got != opsErrorLogMaxQueueBytes-1 {
  62. t.Fatalf("queued bytes = %d, want %d", got, opsErrorLogMaxQueueBytes-1)
  63. }
  64. if got := OpsErrorLogQueueLength(); got != 1 {
  65. t.Fatalf("queue length = %d, want 1", got)
  66. }
  67. }
  68. func TestEstimateOpsErrorLogJobBytesIncludesVariablePayloads(t *testing.T) {
  69. base := estimateOpsErrorLogJobBytes(&service.OpsInsertErrorLogInput{})
  70. message := "upstream message"
  71. detail := "upstream detail"
  72. events := `[{"error":"x"}]`
  73. entry := &service.OpsInsertErrorLogInput{
  74. ErrorBody: strings.Repeat("x", 1024),
  75. ErrorMessage: "client error",
  76. UserAgent: "test-agent",
  77. UpstreamErrorMessage: &message,
  78. UpstreamErrorDetail: &detail,
  79. UpstreamErrorsJSON: &events,
  80. }
  81. if got := estimateOpsErrorLogJobBytes(entry); got <= base+1024 {
  82. t.Fatalf("estimated bytes = %d, expected variable payloads above %d", got, base+1024)
  83. }
  84. }
  85. func resetOpsErrorLoggerStateForTest(t *testing.T) {
  86. t.Helper()
  87. opsErrorLogMu.Lock()
  88. ch := opsErrorLogQueue
  89. opsErrorLogQueue = nil
  90. opsErrorLogStopping = true
  91. opsErrorLogMu.Unlock()
  92. if ch != nil {
  93. close(ch)
  94. }
  95. opsErrorLogWorkersWg.Wait()
  96. opsErrorLogOnce = sync.Once{}
  97. opsErrorLogStopOnce = sync.Once{}
  98. opsErrorLogWorkersWg = sync.WaitGroup{}
  99. opsErrorLogMu = sync.RWMutex{}
  100. opsErrorLogStopping = false
  101. opsErrorLogQueueLen.Store(0)
  102. opsErrorLogEnqueued.Store(0)
  103. opsErrorLogDropped.Store(0)
  104. opsErrorLogProcessed.Store(0)
  105. opsErrorLogSanitized.Store(0)
  106. opsErrorLogLastDropLogAt.Store(0)
  107. opsErrorLogShutdownCh = make(chan struct{})
  108. opsErrorLogShutdownOnce = sync.Once{}
  109. opsErrorLogDrained.Store(false)
  110. }
  111. func TestEnqueueOpsErrorLog_QueueFullDrop(t *testing.T) {
  112. resetOpsErrorLoggerStateForTest(t)
  113. // 禁止 enqueueOpsErrorLog 触发 workers,使用测试队列验证满队列降级。
  114. opsErrorLogOnce.Do(func() {})
  115. opsErrorLogMu.Lock()
  116. opsErrorLogQueue = make(chan opsErrorLogJob, 1)
  117. opsErrorLogMu.Unlock()
  118. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  119. entry := &service.OpsInsertErrorLogInput{ErrorPhase: "upstream", ErrorType: "upstream_error"}
  120. enqueueOpsErrorLog(ops, entry)
  121. enqueueOpsErrorLog(ops, entry)
  122. require.Equal(t, int64(1), OpsErrorLogEnqueuedTotal())
  123. require.Equal(t, int64(1), OpsErrorLogDroppedTotal())
  124. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  125. }
  126. func TestEnqueueOpsErrorLog_EarlyReturnBranches(t *testing.T) {
  127. resetOpsErrorLoggerStateForTest(t)
  128. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  129. entry := &service.OpsInsertErrorLogInput{ErrorPhase: "upstream", ErrorType: "upstream_error"}
  130. // nil 入参分支
  131. enqueueOpsErrorLog(nil, entry)
  132. enqueueOpsErrorLog(ops, nil)
  133. require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
  134. // shutdown 分支
  135. close(opsErrorLogShutdownCh)
  136. enqueueOpsErrorLog(ops, entry)
  137. require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
  138. // stopping 分支
  139. resetOpsErrorLoggerStateForTest(t)
  140. opsErrorLogMu.Lock()
  141. opsErrorLogStopping = true
  142. opsErrorLogMu.Unlock()
  143. enqueueOpsErrorLog(ops, entry)
  144. require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
  145. // queue nil 分支(防止启动 worker 干扰)
  146. resetOpsErrorLoggerStateForTest(t)
  147. opsErrorLogOnce.Do(func() {})
  148. opsErrorLogMu.Lock()
  149. opsErrorLogQueue = nil
  150. opsErrorLogMu.Unlock()
  151. enqueueOpsErrorLog(ops, entry)
  152. require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
  153. }
  154. func TestOpsCaptureWriterPool_ResetOnRelease(t *testing.T) {
  155. gin.SetMode(gin.TestMode)
  156. rec := httptest.NewRecorder()
  157. c, _ := gin.CreateTestContext(rec)
  158. c.Request = httptest.NewRequest(http.MethodGet, "/test", nil)
  159. writer := acquireOpsCaptureWriter(c.Writer)
  160. require.NotNil(t, writer)
  161. c.Writer.WriteHeader(http.StatusInternalServerError)
  162. _, err := writer.WriteString("temp-error-body")
  163. require.NoError(t, err)
  164. require.NotEmpty(t, writer.capturedBytes())
  165. releaseOpsCaptureWriter(writer)
  166. reused := acquireOpsCaptureWriter(c.Writer)
  167. defer releaseOpsCaptureWriter(reused)
  168. require.Empty(t, reused.capturedBytes(), "writer should be reset before reuse")
  169. }
  170. func TestOpsCaptureWriterPool_DropsLargeBuffers(t *testing.T) {
  171. state := &opsCaptureWriterState{}
  172. state.buf.Grow(opsCaptureWriterPoolMaxRetainedCapacity + 1)
  173. require.False(t, shouldPoolOpsCaptureWriterState(state))
  174. }
  175. func TestEnqueueOpsErrorLog_SanitizesAndBoundsBodyBeforeQueue(t *testing.T) {
  176. setupOpsErrorLogTestQueue(t, 1)
  177. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  178. secret := strings.Repeat("s", service.OpsErrorLogQueueBodyMaxBytes)
  179. entry := &service.OpsInsertErrorLogInput{
  180. ErrorPhase: "request",
  181. ErrorType: "api_error",
  182. ErrorBody: `{"authorization":"Bearer ` + secret + `","message":"failed"}`,
  183. }
  184. enqueueOpsErrorLog(ops, entry)
  185. job := <-opsErrorLogQueue
  186. require.LessOrEqual(t, len(job.entry.ErrorBody), service.OpsErrorLogQueueBodyMaxBytes)
  187. require.NotContains(t, job.entry.ErrorBody, secret)
  188. require.Equal(t, int64(1), OpsErrorLogSanitizedTotal())
  189. }
  190. func TestOpsErrorLoggerMiddleware_DoesNotBreakOuterMiddlewares(t *testing.T) {
  191. gin.SetMode(gin.TestMode)
  192. r := gin.New()
  193. r.Use(middleware2.Recovery())
  194. r.Use(middleware2.RequestLogger())
  195. r.Use(middleware2.Logger())
  196. r.GET("/v1/messages", OpsErrorLoggerMiddleware(nil), func(c *gin.Context) {
  197. c.Status(http.StatusNoContent)
  198. })
  199. rec := httptest.NewRecorder()
  200. req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil)
  201. require.NotPanics(t, func() {
  202. r.ServeHTTP(rec, req)
  203. })
  204. require.Equal(t, http.StatusNoContent, rec.Code)
  205. }
  206. // setupOpsErrorLogTestQueue 阻止 enqueueOpsErrorLog 启动真实 worker,改用可检查的测试队列。
  207. func setupOpsErrorLogTestQueue(t *testing.T, size int) {
  208. t.Helper()
  209. resetOpsErrorLoggerStateForTest(t)
  210. opsErrorLogOnce.Do(func() {})
  211. opsErrorLogMu.Lock()
  212. opsErrorLogQueue = make(chan opsErrorLogJob, size)
  213. opsErrorLogMu.Unlock()
  214. }
  215. func TestOpsErrorLoggerMiddleware_HardSkipsIngressRejection(t *testing.T) {
  216. setupOpsErrorLogTestQueue(t, 4)
  217. gin.SetMode(gin.TestMode)
  218. settings := &ingressRejectSettingRepo{}
  219. repo := &ingressRejectOpsRepo{}
  220. ops := service.NewOpsService(repo, settings, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  221. // Construction may read unrelated runtime settings; only request-path reads matter here.
  222. settings.getValueCalls = 0
  223. router := gin.New()
  224. router.Use(OpsErrorLoggerMiddleware(ops))
  225. router.GET("/v1/messages", func(c *gin.Context) {
  226. middleware2.MarkIngressRejected(c, middleware2.IngressRejectInvalidAPIKey)
  227. c.JSON(http.StatusUnauthorized, gin.H{"code": "INVALID_API_KEY", "message": "Invalid API key"})
  228. })
  229. w := httptest.NewRecorder()
  230. req := httptest.NewRequest(http.MethodGet, "/v1/messages", nil)
  231. router.ServeHTTP(w, req)
  232. require.Equal(t, http.StatusUnauthorized, w.Code)
  233. require.JSONEq(t, `{"code":"INVALID_API_KEY","message":"Invalid API key"}`, w.Body.String())
  234. require.Zero(t, settings.getValueCalls, "ingress rejection must bypass monitoring settings reads")
  235. require.Zero(t, repo.insertCalls, "ingress rejection must bypass inserts")
  236. require.Zero(t, OpsErrorLogEnqueuedTotal(), "ingress rejection must not enter the error queue")
  237. }
  238. func TestOpsErrorLoggerMiddleware_DedicatedCyberSessionBlockRecordsExactlyOnce(t *testing.T) {
  239. setupOpsErrorLogTestQueue(t, 3)
  240. gin.SetMode(gin.TestMode)
  241. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  242. h := &OpenAIGatewayHandler{opsService: ops}
  243. apiKey := &service.APIKey{ID: 41, Key: "sk-dedicated-test"}
  244. router := gin.New()
  245. router.Use(OpsErrorLoggerMiddleware(ops))
  246. router.POST("/v1/responses", func(c *gin.Context) {
  247. h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, "gpt-test", "session-block-hash")
  248. c.JSON(http.StatusForbidden, gin.H{"error": gin.H{
  249. "type": "permission_error", "code": "session_blocked_by_cyber_policy", "message": "blocked",
  250. }})
  251. })
  252. recorder := httptest.NewRecorder()
  253. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  254. require.Equal(t, http.StatusForbidden, recorder.Code)
  255. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  256. job := <-opsErrorLogQueue
  257. require.Equal(t, "cyber_policy_session_blocked", job.entry.ErrorType)
  258. require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
  259. }
  260. func TestOpsErrorLoggerMiddleware_OrdinaryPermissionStillRecords(t *testing.T) {
  261. setupOpsErrorLogTestQueue(t, 2)
  262. gin.SetMode(gin.TestMode)
  263. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  264. router := gin.New()
  265. router.Use(OpsErrorLoggerMiddleware(ops))
  266. router.POST("/v1/responses", func(c *gin.Context) {
  267. c.JSON(http.StatusForbidden, gin.H{"error": gin.H{
  268. "type": "permission_error", "code": "permission_denied", "message": "denied",
  269. }})
  270. })
  271. recorder := httptest.NewRecorder()
  272. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  273. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  274. job := <-opsErrorLogQueue
  275. require.Equal(t, "permission_error", job.entry.ErrorType)
  276. require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
  277. }
  278. func TestOpsErrorLoggerMiddleware_RecordsRecoveredUpstreamTelemetryOutsideFailureSLA(t *testing.T) {
  279. setupOpsErrorLogTestQueue(t, 2)
  280. gin.SetMode(gin.TestMode)
  281. repo := &ingressRejectOpsRepo{}
  282. ops := service.NewOpsService(repo, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  283. router := gin.New()
  284. router.Use(OpsErrorLoggerMiddleware(ops))
  285. router.POST("/v1/responses", func(c *gin.Context) {
  286. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
  287. UpstreamStatusCode: http.StatusTooManyRequests,
  288. Message: "earlier attempt was rate limited",
  289. }})
  290. c.JSON(http.StatusOK, gin.H{"status": "completed"})
  291. })
  292. recorder := httptest.NewRecorder()
  293. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  294. require.Equal(t, http.StatusOK, recorder.Code)
  295. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  296. job := <-opsErrorLogQueue
  297. require.Nil(t, job.entry.UpstreamErrors, "raw attempts must be released before async queueing")
  298. require.NotNil(t, job.entry.UpstreamErrorsJSON)
  299. queuedEvents, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
  300. require.NoError(t, err)
  301. require.Len(t, queuedEvents, 1)
  302. require.Equal(t, http.StatusTooManyRequests, queuedEvents[0].UpstreamStatusCode)
  303. flushOpsErrorLogBatch([]opsErrorLogJob{job})
  304. require.Equal(t, 1, repo.insertCalls)
  305. require.Len(t, repo.entries, 1)
  306. persisted := repo.entries[0]
  307. require.Equal(t, http.StatusOK, persisted.StatusCode, "recovered telemetry must remain outside failed-request SLA")
  308. require.Equal(t, "upstream", persisted.ErrorPhase)
  309. require.Equal(t, "upstream_error", persisted.ErrorType)
  310. require.Equal(t, "Recovered upstream error 429: earlier attempt was rate limited", persisted.ErrorMessage)
  311. require.NotNil(t, persisted.UpstreamErrorsJSON)
  312. persistedEvents, err := service.ParseOpsUpstreamErrors(*persisted.UpstreamErrorsJSON)
  313. require.NoError(t, err)
  314. require.Len(t, persistedEvents, 1)
  315. require.Equal(t, http.StatusTooManyRequests, persistedEvents[0].UpstreamStatusCode)
  316. }
  317. func TestOpsErrorLoggerMiddleware_RecoveredTelemetryFiltersSkipMonitoringAttempts(t *testing.T) {
  318. setupOpsErrorLogTestQueue(t, 2)
  319. gin.SetMode(gin.TestMode)
  320. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  321. router := gin.New()
  322. router.Use(OpsErrorLoggerMiddleware(ops))
  323. router.POST("/v1/responses", func(c *gin.Context) {
  324. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  325. {UpstreamStatusCode: http.StatusTooManyRequests, Message: "visible retry"},
  326. {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true},
  327. })
  328. c.JSON(http.StatusOK, gin.H{"status": "completed"})
  329. })
  330. recorder := httptest.NewRecorder()
  331. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  332. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  333. job := <-opsErrorLogQueue
  334. require.Equal(t, "Recovered upstream error 429: visible retry", job.entry.ErrorMessage)
  335. require.NotNil(t, job.entry.UpstreamErrorsJSON)
  336. events, err := service.ParseOpsUpstreamErrors(*job.entry.UpstreamErrorsJSON)
  337. require.NoError(t, err)
  338. require.Len(t, events, 1)
  339. require.Equal(t, "visible retry", events[0].Message)
  340. }
  341. func TestOpsErrorLoggerMiddleware_RecoveredTelemetrySkipsAllHiddenAttempts(t *testing.T) {
  342. setupOpsErrorLogTestQueue(t, 2)
  343. gin.SetMode(gin.TestMode)
  344. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  345. router := gin.New()
  346. router.Use(OpsErrorLoggerMiddleware(ops))
  347. router.POST("/v1/responses", func(c *gin.Context) {
  348. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
  349. UpstreamStatusCode: http.StatusTooManyRequests,
  350. Message: "hidden retry",
  351. SkipMonitoring: true,
  352. }})
  353. c.JSON(http.StatusOK, gin.H{"status": "completed"})
  354. })
  355. recorder := httptest.NewRecorder()
  356. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  357. require.Equal(t, int64(0), OpsErrorLogQueueLength())
  358. }
  359. func TestOpsErrorLoggerMiddleware_IntermediateSkipMonitoringDoesNotHideFinalVisibleFailure(t *testing.T) {
  360. setupOpsErrorLogTestQueue(t, 2)
  361. gin.SetMode(gin.TestMode)
  362. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  363. router := gin.New()
  364. router.Use(OpsErrorLoggerMiddleware(ops))
  365. router.POST("/v1/responses", func(c *gin.Context) {
  366. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  367. {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden retry", SkipMonitoring: true},
  368. {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"},
  369. })
  370. c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{"type": "upstream_error", "message": "visible final"}})
  371. })
  372. recorder := httptest.NewRecorder()
  373. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  374. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  375. job := <-opsErrorLogQueue
  376. require.Equal(t, http.StatusServiceUnavailable, job.entry.StatusCode)
  377. require.Equal(t, "visible final", job.entry.ErrorMessage)
  378. }
  379. func TestOpsErrorLoggerMiddleware_CapturesSplitResponsesFailedSSE(t *testing.T) {
  380. setupOpsErrorLogTestQueue(t, 2)
  381. gin.SetMode(gin.TestMode)
  382. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  383. router := gin.New()
  384. router.Use(OpsErrorLoggerMiddleware(ops))
  385. router.POST("/v1/responses", func(c *gin.Context) {
  386. setOpsRequestContext(c, "gpt-5.5", true)
  387. c.Status(http.StatusOK)
  388. _, _ = c.Writer.Write([]byte("event: response."))
  389. _, _ = c.Writer.Write([]byte("failed\n"))
  390. _, _ = c.Writer.Write([]byte(`data: {"type":"response.failed","response":{"error":{"code":"rate_limit_exceeded","message":"Too many pending requests"}}}`))
  391. _, _ = c.Writer.Write([]byte("\n\n"))
  392. })
  393. recorder := httptest.NewRecorder()
  394. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  395. require.Equal(t, http.StatusOK, recorder.Code)
  396. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  397. job := <-opsErrorLogQueue
  398. require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode)
  399. require.Equal(t, "rate_limit_error", job.entry.ErrorType)
  400. require.Contains(t, job.entry.ErrorMessage, "Too many pending requests")
  401. }
  402. func TestOpsCaptureWriter_CapturesSplitDataOnlyTerminalMarkers(t *testing.T) {
  403. tests := []struct {
  404. name string
  405. prefix string
  406. suffix string
  407. wantType string
  408. wantCode string
  409. wantError string
  410. }{
  411. {
  412. name: "response failed with space",
  413. prefix: `data: {"type":"response.`,
  414. suffix: `failed","response":{"error":{"code":"server_is_overloaded","message":"busy"}}}`,
  415. wantType: "overloaded_error",
  416. wantCode: "server_is_overloaded",
  417. wantError: "busy",
  418. },
  419. {
  420. name: "response failed without space",
  421. prefix: `data:{"type":"response.`,
  422. suffix: `failed","error":{"code":"rate_limit_exceeded","message":"slow down"}}`,
  423. wantType: "rate_limit_error",
  424. wantCode: "rate_limit_exceeded",
  425. wantError: "slow down",
  426. },
  427. {
  428. name: "error with space",
  429. prefix: `data: {"type":"er`,
  430. suffix: `ror","error":{"type":"invalid_request_error","code":"invalid_request","message":"bad input"}}`,
  431. wantType: "invalid_request_error",
  432. wantCode: "invalid_request",
  433. wantError: "bad input",
  434. },
  435. {
  436. name: "error without space",
  437. prefix: `data:{"type":"er`,
  438. suffix: `ror","error":{"type":"authentication_error","code":"authentication_failed","message":"sign in"}}`,
  439. wantType: "authentication_error",
  440. wantCode: "authentication_failed",
  441. wantError: "sign in",
  442. },
  443. }
  444. for _, tt := range tests {
  445. t.Run(tt.name, func(t *testing.T) {
  446. state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
  447. state.captureResponseChunk([]byte(tt.prefix), http.StatusOK)
  448. require.Empty(t, state.buf.Bytes(), "partial frame must remain in the bounded probe")
  449. state.captureResponseChunk([]byte(tt.suffix+"\n\n"), http.StatusOK)
  450. parsed := parseOpsErrorResponse(state.buf.Bytes())
  451. require.True(t, state.sseCapturing)
  452. require.True(t, parsed.StreamFailure)
  453. require.Equal(t, tt.wantType, parsed.ErrorType)
  454. require.Equal(t, tt.wantCode, parsed.Code)
  455. require.Equal(t, tt.wantError, parsed.Message)
  456. require.LessOrEqual(t, len(state.probe), opsTerminalSSEFrameProbeLimit)
  457. })
  458. }
  459. }
  460. func TestOpsErrorLoggerMiddleware_StreamFailureUsesTerminalErrorOverAttemptContext(t *testing.T) {
  461. setupOpsErrorLogTestQueue(t, 2)
  462. gin.SetMode(gin.TestMode)
  463. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  464. router := gin.New()
  465. router.Use(OpsErrorLoggerMiddleware(ops))
  466. router.POST("/v1/responses", func(c *gin.Context) {
  467. service.SetOpsUpstreamError(c, http.StatusBadGateway, "Upstream transport error", "earlier attempt failed")
  468. c.Status(http.StatusOK)
  469. _, _ = c.Writer.WriteString("event: er")
  470. _, _ = c.Writer.WriteString("ror\n")
  471. _, _ = c.Writer.WriteString(`data: {"type":"error","error":{"type":"invalid_request_error","code":"context_length_exceeded","message":"input exceeds the context window"}}`)
  472. _, _ = c.Writer.WriteString("\n\n")
  473. })
  474. recorder := httptest.NewRecorder()
  475. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  476. require.Equal(t, http.StatusOK, recorder.Code)
  477. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  478. job := <-opsErrorLogQueue
  479. require.Equal(t, http.StatusBadRequest, job.entry.StatusCode)
  480. require.Equal(t, "invalid_request_error", job.entry.ErrorType)
  481. require.NotNil(t, job.entry.UpstreamStatusCode)
  482. require.Equal(t, http.StatusBadRequest, *job.entry.UpstreamStatusCode)
  483. require.NotNil(t, job.entry.UpstreamErrorMessage)
  484. require.Equal(t, "input exceeds the context window", *job.entry.UpstreamErrorMessage)
  485. }
  486. func TestOpsErrorLoggerMiddleware_PrefersContextRequestID(t *testing.T) {
  487. setupOpsErrorLogTestQueue(t, 2)
  488. gin.SetMode(gin.TestMode)
  489. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  490. router := gin.New()
  491. router.Use(OpsErrorLoggerMiddleware(ops))
  492. router.POST("/v1/responses", func(c *gin.Context) {
  493. c.Header("X-Request-Id", "response-header-id")
  494. c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": "bad input"}})
  495. })
  496. recorder := httptest.NewRecorder()
  497. request := httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  498. request = request.WithContext(context.WithValue(request.Context(), ctxkey.RequestID, "context-request-id"))
  499. router.ServeHTTP(recorder, request)
  500. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  501. job := <-opsErrorLogQueue
  502. require.Equal(t, "context-request-id", job.entry.RequestID)
  503. }
  504. func TestNormalizeOpsPersistentUserAgentBoundsAndPreservesUTF8(t *testing.T) {
  505. value := strings.Repeat("a", opsErrorLogMaxUserAgentBytes-1) + "你" + strings.Repeat("b", 32)
  506. got := normalizeOpsPersistentUserAgent(" " + value + " ")
  507. require.LessOrEqual(t, len(got), opsErrorLogMaxUserAgentBytes)
  508. require.True(t, utf8.ValidString(got))
  509. require.NotContains(t, got, "b")
  510. }
  511. // 就地(in-band) SSE 错误挂在已固化的 HTTP 200 流上:wire 状态码为 200,
  512. // 常规 status>=400 采集路径不会触发。logOpsStreamError 必须据 MarkOpsStreamError
  513. // 补记一条错误日志,且用 IntendedStatus(429) 分级、StatusCode 仍记 wire 的 200。
  514. func TestLogOpsStreamError_RecordsInBandConcurrencyLimit(t *testing.T) {
  515. setupOpsErrorLogTestQueue(t, 4)
  516. gin.SetMode(gin.TestMode)
  517. rec := httptest.NewRecorder()
  518. c, _ := gin.CreateTestContext(rec)
  519. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  520. c.Set(opsModelKey, "test-model")
  521. service.MarkOpsStreamError(c, "rate_limit_error",
  522. "Concurrency limit exceeded for account, please retry later", http.StatusTooManyRequests)
  523. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  524. logOpsStreamError(c, ops, http.StatusOK)
  525. require.Equal(t, int64(1), OpsErrorLogEnqueuedTotal())
  526. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  527. job := <-opsErrorLogQueue
  528. require.NotNil(t, job.entry)
  529. require.Equal(t, "rate_limit_error", job.entry.ErrorType)
  530. require.Equal(t, "request", job.entry.ErrorPhase)
  531. require.True(t, job.entry.IsBusinessLimited)
  532. require.True(t, job.entry.Stream)
  533. require.Equal(t, http.StatusOK, job.entry.StatusCode) // wire 状态码保持 200
  534. require.Equal(t, "P1", job.entry.Severity) // 用 IntendedStatus 429 分级
  535. require.Equal(t, "test-model", job.entry.Model)
  536. require.Equal(t, "Concurrency limit exceeded for account, please retry later", job.entry.ErrorMessage)
  537. }
  538. func TestLogOpsStreamError_UpstreamFailureCountsTowardsSLA(t *testing.T) {
  539. setupOpsErrorLogTestQueue(t, 4)
  540. gin.SetMode(gin.TestMode)
  541. rec := httptest.NewRecorder()
  542. c, _ := gin.CreateTestContext(rec)
  543. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
  544. c.Set(opsModelKey, "gpt-5.6-sol")
  545. service.MarkOpsStreamFailure(
  546. c,
  547. "upstream_error",
  548. service.OpenAIUpstreamHTTP2StreamErrorCode,
  549. "Upstream HTTP/2 stream failed",
  550. http.StatusBadGateway,
  551. )
  552. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  553. logOpsStreamError(c, ops, http.StatusOK)
  554. job := <-opsErrorLogQueue
  555. require.NotNil(t, job.entry)
  556. require.Equal(t, http.StatusBadGateway, job.entry.StatusCode)
  557. require.Equal(t, "upstream_error", job.entry.ErrorType)
  558. require.Equal(t, "upstream", job.entry.ErrorPhase)
  559. require.Equal(t, "provider", job.entry.ErrorOwner)
  560. require.False(t, job.entry.IsBusinessLimited)
  561. require.Contains(t, job.entry.ErrorBody, service.OpenAIUpstreamHTTP2StreamErrorCode)
  562. }
  563. // 未标记流内错误时 logOpsStreamError 必须是 no-op(不误记正常的 200 流)。
  564. func TestLogOpsStreamError_NoopWhenNotMarked(t *testing.T) {
  565. setupOpsErrorLogTestQueue(t, 4)
  566. gin.SetMode(gin.TestMode)
  567. rec := httptest.NewRecorder()
  568. c, _ := gin.CreateTestContext(rec)
  569. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  570. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  571. logOpsStreamError(c, ops, http.StatusOK)
  572. require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
  573. }
  574. // 命中 skip_monitoring=true 透传规则时不落库,与其它采集分支一致。
  575. func TestLogOpsStreamError_SkipWhenPassthroughSkipMonitoring(t *testing.T) {
  576. setupOpsErrorLogTestQueue(t, 4)
  577. gin.SetMode(gin.TestMode)
  578. rec := httptest.NewRecorder()
  579. c, _ := gin.CreateTestContext(rec)
  580. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  581. service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway)
  582. c.Set(service.OpsSkipPassthroughKey, true)
  583. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  584. logOpsStreamError(c, ops, http.StatusOK)
  585. require.Equal(t, int64(0), OpsErrorLogEnqueuedTotal())
  586. }
  587. func TestShouldSkipFinalOpsFailureUsesOnlyFinalAttemptRule(t *testing.T) {
  588. gin.SetMode(gin.TestMode)
  589. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  590. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  591. {UpstreamStatusCode: http.StatusBadGateway, Message: "hidden intermediate", SkipMonitoring: true},
  592. {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "visible final"},
  593. })
  594. require.False(t, shouldSkipFinalOpsFailure(c))
  595. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  596. {UpstreamStatusCode: http.StatusBadGateway, Message: "visible intermediate"},
  597. nil,
  598. {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "hidden final", SkipMonitoring: true},
  599. })
  600. require.True(t, shouldSkipFinalOpsFailure(c))
  601. }
  602. // MarkOpsStreamError 采用「首个标记生效」:后续的通用兜底帧不得覆盖根因错误。
  603. func TestMarkOpsStreamError_FirstWins(t *testing.T) {
  604. gin.SetMode(gin.TestMode)
  605. rec := httptest.NewRecorder()
  606. c, _ := gin.CreateTestContext(rec)
  607. service.MarkOpsStreamError(c, "rate_limit_error", "Concurrency limit exceeded for account", http.StatusTooManyRequests)
  608. service.MarkOpsStreamError(c, "upstream_error", "Upstream request failed", http.StatusBadGateway)
  609. se, ok := service.GetOpsStreamError(c)
  610. require.True(t, ok)
  611. require.Equal(t, "rate_limit_error", se.ErrType)
  612. require.Equal(t, "Concurrency limit exceeded for account", se.Message)
  613. require.Equal(t, http.StatusTooManyRequests, se.IntendedStatus)
  614. }
  615. func TestLogOpsStreamError_RecordsOneFailurePerWebSocketTurn(t *testing.T) {
  616. setupOpsErrorLogTestQueue(t, 4)
  617. gin.SetMode(gin.TestMode)
  618. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  619. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  620. service.SetOpenAIClientTransport(c, service.OpenAIClientTransportWS)
  621. service.BeginOpsStreamTurn(c, 1)
  622. service.MarkOpsStreamFailure(c, "rate_limit_error", "rate_limit_exceeded", "turn one failed", http.StatusTooManyRequests)
  623. service.MarkOpsStreamError(c, "upstream_error", "generic duplicate for turn one", http.StatusBadGateway)
  624. service.BeginOpsStreamTurn(c, 2)
  625. service.MarkOpsStreamFailure(c, "permission_error", "permission_denied", "turn two failed", http.StatusForbidden)
  626. streamErrors := service.GetOpsStreamErrors(c)
  627. require.Len(t, streamErrors, 2)
  628. require.Equal(t, 1, streamErrors[0].Turn)
  629. require.Equal(t, 2, streamErrors[1].Turn)
  630. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  631. logOpsStreamError(c, ops, http.StatusSwitchingProtocols)
  632. require.Equal(t, int64(2), OpsErrorLogQueueLength())
  633. first := <-opsErrorLogQueue
  634. second := <-opsErrorLogQueue
  635. require.Equal(t, "turn one failed", first.entry.ErrorMessage)
  636. require.Equal(t, http.StatusTooManyRequests, first.entry.StatusCode)
  637. require.Equal(t, "turn two failed", second.entry.ErrorMessage)
  638. require.Equal(t, http.StatusForbidden, second.entry.StatusCode)
  639. }
  640. func TestIsKnownOpsErrorType(t *testing.T) {
  641. known := []string{
  642. "invalid_request_error",
  643. "authentication_error",
  644. "rate_limit_error",
  645. "billing_error",
  646. "subscription_error",
  647. "upstream_error",
  648. "overloaded_error",
  649. "api_error",
  650. "not_found_error",
  651. "forbidden_error",
  652. }
  653. for _, k := range known {
  654. require.True(t, isKnownOpsErrorType(k), "expected known: %s", k)
  655. }
  656. unknown := []string{"<nil>", "null", "", "random_error", "some_new_type", "<nil>\u003e"}
  657. for _, u := range unknown {
  658. require.False(t, isKnownOpsErrorType(u), "expected unknown: %q", u)
  659. }
  660. }
  661. func TestNormalizeOpsErrorType(t *testing.T) {
  662. tests := []struct {
  663. name string
  664. errType string
  665. code string
  666. want string
  667. }{
  668. // Known types pass through.
  669. {"known invalid_request_error", "invalid_request_error", "", "invalid_request_error"},
  670. {"known rate_limit_error", "rate_limit_error", "", "rate_limit_error"},
  671. {"known upstream_error", "upstream_error", "", "upstream_error"},
  672. // Unknown/garbage types are rejected and fall through to code-based or default.
  673. {"nil literal from upstream", "<nil>", "", "api_error"},
  674. {"null string", "null", "", "api_error"},
  675. {"random string", "something_weird", "", "api_error"},
  676. // Unknown type but known code still maps correctly.
  677. {"nil with INSUFFICIENT_BALANCE code", "<nil>", "INSUFFICIENT_BALANCE", "billing_error"},
  678. {"nil with USAGE_LIMIT_EXCEEDED code", "<nil>", "USAGE_LIMIT_EXCEEDED", "subscription_error"},
  679. // Empty type falls through to code-based mapping.
  680. {"empty type with balance code", "", "INSUFFICIENT_BALANCE", "billing_error"},
  681. {"empty type with subscription code", "", "SUBSCRIPTION_NOT_FOUND", "subscription_error"},
  682. {"empty type no code", "", "", "api_error"},
  683. // Known type overrides conflicting code-based mapping.
  684. {"known type overrides conflicting code", "rate_limit_error", "INSUFFICIENT_BALANCE", "rate_limit_error"},
  685. }
  686. for _, tt := range tests {
  687. t.Run(tt.name, func(t *testing.T) {
  688. got := normalizeOpsErrorType(tt.errType, tt.code)
  689. require.Equal(t, tt.want, got)
  690. })
  691. }
  692. }
  693. func TestClassifyOpsNoAvailableAccountsExcludedFromSLA(t *testing.T) {
  694. const message = "No available accounts"
  695. gin.SetMode(gin.TestMode)
  696. rec := httptest.NewRecorder()
  697. c, _ := gin.CreateTestContext(rec)
  698. markOpsRoutingCapacityLimited(c)
  699. errType := normalizeOpsErrorType("api_error", "")
  700. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, message, "", http.StatusServiceUnavailable)
  701. require.Equal(t, "api_error", errType)
  702. require.Equal(t, "routing", phase)
  703. require.True(t, isBusinessLimited)
  704. require.Equal(t, "platform", errorOwner)
  705. require.Equal(t, "gateway", errorSource)
  706. }
  707. func TestClassifyOpsRoutingCapacityMarkerExcludesMaskedSelectionFailureFromSLA(t *testing.T) {
  708. gin.SetMode(gin.TestMode)
  709. rec := httptest.NewRecorder()
  710. c, _ := gin.CreateTestContext(rec)
  711. markOpsRoutingCapacityLimited(c)
  712. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
  713. c,
  714. "api_error",
  715. "Service temporarily unavailable",
  716. "",
  717. http.StatusServiceUnavailable,
  718. )
  719. require.Equal(t, "routing", phase)
  720. require.True(t, isBusinessLimited)
  721. require.Equal(t, "platform", errorOwner)
  722. require.Equal(t, "gateway", errorSource)
  723. }
  724. func TestClassifyOpsLocalModelConfigurationRejection(t *testing.T) {
  725. gin.SetMode(gin.TestMode)
  726. rec := httptest.NewRecorder()
  727. c, _ := gin.CreateTestContext(rec)
  728. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  729. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
  730. c,
  731. "model_not_found",
  732. "Model \"gpt-missing\" is not supported by any configured account in this group",
  733. "",
  734. http.StatusNotFound,
  735. )
  736. require.Equal(t, "routing", phase)
  737. require.True(t, isBusinessLimited)
  738. require.Equal(t, "platform", errorOwner)
  739. require.Equal(t, "gateway", errorSource)
  740. }
  741. func TestClassifyOpsLocalModelConfigurationOverridesStaleUpstreamMarkers(t *testing.T) {
  742. gin.SetMode(gin.TestMode)
  743. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  744. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  745. c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
  746. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
  747. Stage: string(service.GatewayFailureStageAccountAuth),
  748. UpstreamStatusCode: http.StatusUnauthorized,
  749. }})
  750. phase, limited, owner, source := classifyOpsErrorLog(c, "model_not_found", "unsupported configured model", "", http.StatusNotFound)
  751. require.Equal(t, "routing", phase)
  752. require.True(t, limited)
  753. require.Equal(t, "platform", owner)
  754. require.Equal(t, "gateway", source)
  755. }
  756. func TestClassifyOpsLocalModelConfigurationRequiresMarkerAndReason(t *testing.T) {
  757. gin.SetMode(gin.TestMode)
  758. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  759. c.Set(service.OpsClientBusinessLimitedReasonKey, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  760. c.Set(service.OpsUpstreamStatusCodeKey, http.StatusBadGateway)
  761. phase, limited, owner, source := classifyOpsErrorLog(c, "upstream_error", "provider failed", "", http.StatusBadGateway)
  762. require.Equal(t, "upstream", phase)
  763. require.False(t, limited)
  764. require.Equal(t, "provider", owner)
  765. require.Equal(t, "upstream_http", source)
  766. }
  767. func TestOpsErrorLoggerMiddleware_LocalModelConfigurationFields(t *testing.T) {
  768. setupOpsErrorLogTestQueue(t, 1)
  769. gin.SetMode(gin.TestMode)
  770. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  771. router := gin.New()
  772. router.Use(OpsErrorLoggerMiddleware(ops))
  773. router.POST("/v1/chat/completions", func(c *gin.Context) {
  774. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  775. c.Set(opsAccountIDKey, int64(99))
  776. c.Set(opsUpstreamModelKey, "stale-upstream-model")
  777. setActualUpstreamEndpoint(c, "/v1/chat/completions")
  778. c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
  779. c.Set(service.OpsUpstreamErrorMessageKey, "stale upstream error")
  780. c.Set(service.OpsUpstreamErrorDetailKey, "stale upstream detail")
  781. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
  782. Stage: string(service.GatewayFailureStageAccountAuth),
  783. UpstreamStatusCode: http.StatusUnauthorized,
  784. Message: "stale auth failure",
  785. }})
  786. c.JSON(http.StatusNotFound, gin.H{
  787. "error": gin.H{
  788. "type": "model_not_found",
  789. "message": "Model \"gpt-missing\" is not supported by any configured account in this group",
  790. },
  791. })
  792. })
  793. w := httptest.NewRecorder()
  794. req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
  795. router.ServeHTTP(w, req)
  796. require.Equal(t, http.StatusNotFound, w.Code)
  797. require.JSONEq(t, `{"error":{"type":"model_not_found","message":"Model \"gpt-missing\" is not supported by any configured account in this group"}}`, w.Body.String())
  798. job := <-opsErrorLogQueue
  799. require.Equal(t, http.StatusNotFound, job.entry.StatusCode)
  800. require.Equal(t, "routing", job.entry.ErrorPhase)
  801. require.True(t, job.entry.IsBusinessLimited)
  802. require.Equal(t, "platform", job.entry.ErrorOwner)
  803. require.Equal(t, "gateway", job.entry.ErrorSource)
  804. require.Nil(t, job.entry.AccountID)
  805. require.Nil(t, job.entry.UpstreamStatusCode)
  806. require.Nil(t, job.entry.UpstreamErrors)
  807. require.Nil(t, job.entry.UpstreamErrorMessage)
  808. require.Nil(t, job.entry.UpstreamErrorDetail)
  809. require.Empty(t, job.entry.UpstreamModel)
  810. require.Empty(t, job.entry.UpstreamEndpoint)
  811. }
  812. func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) {
  813. tests := []struct {
  814. name string
  815. errType string
  816. message string
  817. code string
  818. status int
  819. }{
  820. {
  821. name: "standard invalid API key",
  822. errType: "api_error",
  823. message: "Invalid API key",
  824. code: "INVALID_API_KEY",
  825. status: http.StatusUnauthorized,
  826. },
  827. {
  828. name: "standard missing API key",
  829. errType: "api_error",
  830. message: "API key is required in Authorization header (Bearer scheme), x-api-key header, or x-goog-api-key header",
  831. code: "API_KEY_REQUIRED",
  832. status: http.StatusUnauthorized,
  833. },
  834. {
  835. name: "expired local API key",
  836. errType: "api_error",
  837. message: "API key 已过期",
  838. code: "API_KEY_EXPIRED",
  839. status: http.StatusForbidden,
  840. },
  841. {
  842. name: "disabled local API key",
  843. errType: "api_error",
  844. message: "API key is disabled",
  845. code: "API_KEY_DISABLED",
  846. status: http.StatusUnauthorized,
  847. },
  848. {
  849. name: "local API key user missing",
  850. errType: "api_error",
  851. message: "User associated with API key not found",
  852. code: "USER_NOT_FOUND",
  853. status: http.StatusUnauthorized,
  854. },
  855. {
  856. name: "inactive local API key user",
  857. errType: "api_error",
  858. message: "User account is not active",
  859. code: "USER_INACTIVE",
  860. status: http.StatusUnauthorized,
  861. },
  862. {
  863. name: "deleted local API key group",
  864. errType: "api_error",
  865. message: "API Key 所属分组已删除",
  866. code: "GROUP_DELETED",
  867. status: http.StatusForbidden,
  868. },
  869. {
  870. name: "disabled local API key group",
  871. errType: "api_error",
  872. message: "API Key 所属分组已停用",
  873. code: "GROUP_DISABLED",
  874. status: http.StatusForbidden,
  875. },
  876. {
  877. name: "google deleted API key group message without semantic code",
  878. errType: "api_error",
  879. message: "API Key 所属分组已删除",
  880. code: "403",
  881. status: http.StatusForbidden,
  882. },
  883. {
  884. name: "anthropic unassigned API key group",
  885. errType: "permission_error",
  886. message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.",
  887. code: "",
  888. status: http.StatusForbidden,
  889. },
  890. {
  891. name: "google invalid API key",
  892. errType: "api_error",
  893. message: "Invalid API key",
  894. code: "401",
  895. status: http.StatusUnauthorized,
  896. },
  897. {
  898. name: "google missing API key",
  899. errType: "api_error",
  900. message: "API key is required",
  901. code: "401",
  902. status: http.StatusUnauthorized,
  903. },
  904. {
  905. name: "google disabled API key",
  906. errType: "api_error",
  907. message: "API key is disabled",
  908. code: "401",
  909. status: http.StatusUnauthorized,
  910. },
  911. {
  912. name: "google local API key user missing",
  913. errType: "api_error",
  914. message: "User associated with API key not found",
  915. code: "401",
  916. status: http.StatusUnauthorized,
  917. },
  918. {
  919. name: "google inactive local API key user",
  920. errType: "api_error",
  921. message: "User account is not active",
  922. code: "401",
  923. status: http.StatusUnauthorized,
  924. },
  925. }
  926. for _, tt := range tests {
  927. t.Run(tt.name, func(t *testing.T) {
  928. gin.SetMode(gin.TestMode)
  929. rec := httptest.NewRecorder()
  930. c, _ := gin.CreateTestContext(rec)
  931. errType := normalizeOpsErrorType(tt.errType, tt.code)
  932. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, tt.message, tt.code, tt.status)
  933. wantErrType := "api_error"
  934. if tt.errType == "permission_error" {
  935. wantErrType = "permission_error"
  936. }
  937. require.Equal(t, wantErrType, errType)
  938. require.Equal(t, "auth", phase)
  939. require.True(t, isBusinessLimited)
  940. require.Equal(t, "client", errorOwner)
  941. require.Equal(t, "client_request", errorSource)
  942. })
  943. }
  944. }
  945. func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
  946. tests := []struct {
  947. name string
  948. errType string
  949. message string
  950. code string
  951. status int
  952. wantErrType string
  953. wantPhase string
  954. }{
  955. {
  956. name: "standard API key quota exhausted",
  957. errType: "api_error",
  958. message: "API key 额度已用完",
  959. code: "API_KEY_QUOTA_EXHAUSTED",
  960. status: http.StatusTooManyRequests,
  961. wantErrType: "api_error",
  962. wantPhase: "request",
  963. },
  964. {
  965. name: "standard query API key deprecated",
  966. errType: "api_error",
  967. message: "API key in query parameter is deprecated. Please use Authorization header instead.",
  968. code: "api_key_in_query_deprecated",
  969. status: http.StatusBadRequest,
  970. wantErrType: "api_error",
  971. wantPhase: "request",
  972. },
  973. {
  974. name: "google query API key deprecated",
  975. errType: "api_error",
  976. message: "Query parameter api_key is deprecated. Use Authorization header or key instead.",
  977. code: "400",
  978. status: http.StatusBadRequest,
  979. wantErrType: "api_error",
  980. wantPhase: "request",
  981. },
  982. {
  983. name: "google no active subscription",
  984. errType: "api_error",
  985. message: "No active subscription found for this group",
  986. code: "403",
  987. status: http.StatusForbidden,
  988. wantErrType: "api_error",
  989. wantPhase: "request",
  990. },
  991. {
  992. name: "gateway subscription invalid cache recheck",
  993. errType: "billing_error",
  994. message: "subscription is invalid or expired",
  995. code: "billing_error",
  996. status: http.StatusForbidden,
  997. wantErrType: "billing_error",
  998. wantPhase: "request",
  999. },
  1000. {
  1001. name: "google insufficient account balance",
  1002. errType: "api_error",
  1003. message: "Insufficient account balance",
  1004. code: "403",
  1005. status: http.StatusForbidden,
  1006. wantErrType: "api_error",
  1007. wantPhase: "request",
  1008. },
  1009. {
  1010. name: "gateway billing cache insufficient balance",
  1011. errType: "billing_error",
  1012. message: "insufficient balance",
  1013. code: "",
  1014. status: http.StatusForbidden,
  1015. wantErrType: "billing_error",
  1016. wantPhase: "request",
  1017. },
  1018. {
  1019. name: "gemini group platform mismatch",
  1020. errType: "api_error",
  1021. message: "API key group platform is not gemini",
  1022. code: "400",
  1023. status: http.StatusBadRequest,
  1024. wantErrType: "api_error",
  1025. wantPhase: "request",
  1026. },
  1027. {
  1028. name: "gateway API key 5h rate limit",
  1029. errType: "api_error",
  1030. message: "api key 5小时限额已用完",
  1031. code: "rate_limit_exceeded",
  1032. status: http.StatusTooManyRequests,
  1033. wantErrType: "api_error",
  1034. wantPhase: "request",
  1035. },
  1036. {
  1037. name: "gateway group RPM limit",
  1038. errType: "api_error",
  1039. message: "group requests-per-minute limit exceeded",
  1040. code: "rate_limit_exceeded",
  1041. status: http.StatusTooManyRequests,
  1042. wantErrType: "api_error",
  1043. wantPhase: "request",
  1044. },
  1045. {
  1046. name: "google subscription daily limit",
  1047. errType: "api_error",
  1048. message: "daily usage limit exceeded",
  1049. code: "429",
  1050. status: http.StatusTooManyRequests,
  1051. wantErrType: "api_error",
  1052. wantPhase: "request",
  1053. },
  1054. {
  1055. name: "user platform daily quota exhausted",
  1056. errType: "api_error",
  1057. message: "Daily usage quota exhausted for this platform.",
  1058. code: "rate_limit_exceeded",
  1059. status: http.StatusTooManyRequests,
  1060. wantErrType: "api_error",
  1061. wantPhase: "request",
  1062. },
  1063. {
  1064. name: "local pending queue limit",
  1065. errType: "rate_limit_error",
  1066. message: "Too many pending requests, please retry later",
  1067. code: "",
  1068. status: http.StatusTooManyRequests,
  1069. wantErrType: "rate_limit_error",
  1070. wantPhase: "request",
  1071. },
  1072. {
  1073. name: "local concurrency limit",
  1074. errType: "rate_limit_error",
  1075. message: "Concurrency limit exceeded for user, please retry later",
  1076. code: "",
  1077. status: http.StatusTooManyRequests,
  1078. wantErrType: "rate_limit_error",
  1079. wantPhase: "request",
  1080. },
  1081. {
  1082. name: "group claude code only feature gate",
  1083. errType: "permission_error",
  1084. message: "This group is restricted to Claude Code clients (/v1/messages only)",
  1085. code: "",
  1086. status: http.StatusForbidden,
  1087. wantErrType: "permission_error",
  1088. wantPhase: "request",
  1089. },
  1090. {
  1091. name: "group image generation feature gate",
  1092. errType: "permission_error",
  1093. message: "Image generation is not enabled for this group",
  1094. code: "",
  1095. status: http.StatusForbidden,
  1096. wantErrType: "permission_error",
  1097. wantPhase: "request",
  1098. },
  1099. {
  1100. name: "group reasoning effort over limit deny",
  1101. errType: "permission_error",
  1102. message: `reasoning effort "high" exceeds this group's limit of "low"`,
  1103. code: "",
  1104. status: http.StatusForbidden,
  1105. wantErrType: "permission_error",
  1106. wantPhase: "request",
  1107. },
  1108. {
  1109. name: "route token counting platform unsupported",
  1110. errType: "not_found_error",
  1111. message: "Token counting is not supported for this platform",
  1112. code: "",
  1113. status: http.StatusNotFound,
  1114. wantErrType: "not_found_error",
  1115. wantPhase: "request",
  1116. },
  1117. {
  1118. name: "route images API platform unsupported",
  1119. errType: "not_found_error",
  1120. message: "Images API is not supported for this platform",
  1121. code: "",
  1122. status: http.StatusNotFound,
  1123. wantErrType: "not_found_error",
  1124. wantPhase: "request",
  1125. },
  1126. {
  1127. name: "antigravity model whitelist feature gate",
  1128. errType: "permission_error",
  1129. message: "model claude-3-5-sonnet not in whitelist",
  1130. code: "",
  1131. status: http.StatusForbidden,
  1132. wantErrType: "permission_error",
  1133. wantPhase: "request",
  1134. },
  1135. {
  1136. name: "google antigravity model whitelist feature gate",
  1137. errType: "api_error",
  1138. message: "model gemini-2.5-pro not in whitelist",
  1139. code: "403",
  1140. status: http.StatusForbidden,
  1141. wantErrType: "api_error",
  1142. wantPhase: "request",
  1143. },
  1144. {
  1145. name: "claude beta policy block",
  1146. errType: "invalid_request_error",
  1147. message: "beta feature interleaved-thinking-2025-05-14 is not allowed",
  1148. code: "",
  1149. status: http.StatusBadRequest,
  1150. wantErrType: "invalid_request_error",
  1151. wantPhase: "request",
  1152. },
  1153. {
  1154. name: "openai fast policy block",
  1155. errType: "permission_error",
  1156. message: "openai service_tier=priority is not allowed for model gpt-5.5",
  1157. code: "",
  1158. status: http.StatusForbidden,
  1159. wantErrType: "permission_error",
  1160. wantPhase: "request",
  1161. },
  1162. {
  1163. name: "codex official client policy block",
  1164. errType: "forbidden_error",
  1165. message: "This account only allows Codex official clients",
  1166. code: "",
  1167. status: http.StatusForbidden,
  1168. wantErrType: "forbidden_error",
  1169. wantPhase: "request",
  1170. },
  1171. {
  1172. name: "openai wsv1 unsupported feature gate",
  1173. errType: "invalid_request_error",
  1174. message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
  1175. code: "",
  1176. status: http.StatusBadRequest,
  1177. wantErrType: "invalid_request_error",
  1178. wantPhase: "request",
  1179. },
  1180. {
  1181. name: "openai passthrough instructions policy block",
  1182. errType: "forbidden_error",
  1183. message: "OpenAI codex passthrough requires a non-empty instructions field",
  1184. code: "",
  1185. status: http.StatusForbidden,
  1186. wantErrType: "forbidden_error",
  1187. wantPhase: "request",
  1188. },
  1189. }
  1190. for _, tt := range tests {
  1191. t.Run(tt.name, func(t *testing.T) {
  1192. gin.SetMode(gin.TestMode)
  1193. rec := httptest.NewRecorder()
  1194. c, _ := gin.CreateTestContext(rec)
  1195. errType := normalizeOpsErrorType(tt.errType, tt.code)
  1196. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, tt.message, tt.code, tt.status)
  1197. require.Equal(t, tt.wantErrType, errType)
  1198. require.Equal(t, tt.wantPhase, phase)
  1199. require.True(t, isBusinessLimited)
  1200. require.Equal(t, "client", errorOwner)
  1201. require.Equal(t, "client_request", errorSource)
  1202. })
  1203. }
  1204. }
  1205. func TestClassifyOpsIPRestrictionAccessDeniedExcludedFromSLA(t *testing.T) {
  1206. gin.SetMode(gin.TestMode)
  1207. rec := httptest.NewRecorder()
  1208. c, _ := gin.CreateTestContext(rec)
  1209. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonIPRestriction)
  1210. errType := normalizeOpsErrorType("api_error", "ACCESS_DENIED")
  1211. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "Access denied", "ACCESS_DENIED", http.StatusForbidden)
  1212. require.Equal(t, "api_error", errType)
  1213. require.Equal(t, "auth", phase)
  1214. require.True(t, isBusinessLimited)
  1215. require.Equal(t, "client", errorOwner)
  1216. require.Equal(t, "client_request", errorSource)
  1217. }
  1218. func TestClassifyOpsClientBusinessLimitedMarkerExcludesCustomPolicyDenialFromSLA(t *testing.T) {
  1219. gin.SetMode(gin.TestMode)
  1220. rec := httptest.NewRecorder()
  1221. c, _ := gin.CreateTestContext(rec)
  1222. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
  1223. errType := normalizeOpsErrorType("invalid_request_error", "")
  1224. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "custom admin policy message", "", http.StatusBadRequest)
  1225. require.Equal(t, "invalid_request_error", errType)
  1226. require.Equal(t, "auth", phase)
  1227. require.True(t, isBusinessLimited)
  1228. require.Equal(t, "client", errorOwner)
  1229. require.Equal(t, "client_request", errorSource)
  1230. }
  1231. func TestClassifyOpsOtherErrorsStillCountForSLA(t *testing.T) {
  1232. gin.SetMode(gin.TestMode)
  1233. rec := httptest.NewRecorder()
  1234. c, _ := gin.CreateTestContext(rec)
  1235. errType := normalizeOpsErrorType("api_error", "INTERNAL_ERROR")
  1236. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, "Failed to validate API key", "INTERNAL_ERROR", http.StatusInternalServerError)
  1237. require.Equal(t, "api_error", errType)
  1238. require.Equal(t, "internal", phase)
  1239. require.False(t, isBusinessLimited)
  1240. require.Equal(t, "platform", errorOwner)
  1241. require.Equal(t, "gateway", errorSource)
  1242. }
  1243. func TestClassifyOpsUnsupportedModelExcludedFromSLA(t *testing.T) {
  1244. tests := []string{
  1245. "No available accounts: no available accounts supporting model: made-up-model",
  1246. "No available accounts: no available OpenAI accounts supporting model: made-up-model",
  1247. "No available Gemini accounts: no available Gemini accounts supporting model: made-up-model",
  1248. "No available accounts: no available accounts supporting model: made-up-model (channel pricing restriction)",
  1249. }
  1250. for _, message := range tests {
  1251. t.Run(message, func(t *testing.T) {
  1252. gin.SetMode(gin.TestMode)
  1253. rec := httptest.NewRecorder()
  1254. c, _ := gin.CreateTestContext(rec)
  1255. markOpsRoutingCapacityLimited(c)
  1256. errType := normalizeOpsErrorType("api_error", "")
  1257. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, errType, message, "", http.StatusServiceUnavailable)
  1258. require.Equal(t, "api_error", errType)
  1259. require.Equal(t, "routing", phase)
  1260. require.True(t, isBusinessLimited)
  1261. require.Equal(t, "platform", errorOwner)
  1262. require.Equal(t, "gateway", errorSource)
  1263. })
  1264. }
  1265. }
  1266. func TestClassifyOpsUnmarkedNoAvailableTextStillCountsForSLA(t *testing.T) {
  1267. gin.SetMode(gin.TestMode)
  1268. rec := httptest.NewRecorder()
  1269. c, _ := gin.CreateTestContext(rec)
  1270. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
  1271. c,
  1272. "api_error",
  1273. "No available accounts",
  1274. "",
  1275. http.StatusServiceUnavailable,
  1276. )
  1277. require.Equal(t, "routing", phase)
  1278. require.False(t, isBusinessLimited)
  1279. require.Equal(t, "platform", errorOwner)
  1280. require.Equal(t, "gateway", errorSource)
  1281. }
  1282. func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
  1283. tests := []struct {
  1284. name string
  1285. message string
  1286. code string
  1287. status int
  1288. }{
  1289. {
  1290. name: "invalid API key",
  1291. message: "Invalid API key",
  1292. code: "401",
  1293. status: http.StatusUnauthorized,
  1294. },
  1295. {
  1296. name: "disabled API key",
  1297. message: "API key is disabled",
  1298. code: "API_KEY_DISABLED",
  1299. status: http.StatusUnauthorized,
  1300. },
  1301. {
  1302. name: "gemini group platform mismatch",
  1303. message: "API key group platform is not gemini",
  1304. code: "400",
  1305. status: http.StatusBadRequest,
  1306. },
  1307. {
  1308. name: "provider balance error",
  1309. message: "Insufficient account balance",
  1310. code: "INSUFFICIENT_BALANCE",
  1311. status: http.StatusForbidden,
  1312. },
  1313. {
  1314. name: "provider subscription error",
  1315. message: "No active subscription found for this group",
  1316. code: "SUBSCRIPTION_NOT_FOUND",
  1317. status: http.StatusForbidden,
  1318. },
  1319. {
  1320. name: "provider quota error",
  1321. message: "api key 额度已用完",
  1322. code: "API_KEY_QUOTA_EXHAUSTED",
  1323. status: http.StatusTooManyRequests,
  1324. },
  1325. {
  1326. name: "provider deleted group shaped error",
  1327. message: "API Key 所属分组已删除",
  1328. code: "GROUP_DELETED",
  1329. status: http.StatusForbidden,
  1330. },
  1331. {
  1332. name: "provider unassigned group shaped error",
  1333. message: "API Key is not assigned to any group and cannot be used. Please contact the administrator to assign it to a group.",
  1334. code: "403",
  1335. status: http.StatusForbidden,
  1336. },
  1337. {
  1338. name: "provider local quota shaped error",
  1339. message: "Daily usage quota exhausted for this platform.",
  1340. code: "rate_limit_exceeded",
  1341. status: http.StatusTooManyRequests,
  1342. },
  1343. {
  1344. name: "provider feature gate shaped error",
  1345. message: "Image generation is not enabled for this group",
  1346. code: "403",
  1347. status: http.StatusForbidden,
  1348. },
  1349. {
  1350. name: "provider reasoning effort over limit shaped error",
  1351. message: `reasoning effort "high" exceeds this group's limit of "low"`,
  1352. code: "403",
  1353. status: http.StatusForbidden,
  1354. },
  1355. {
  1356. name: "provider token counting unsupported shaped error",
  1357. message: "Token counting is not supported for this platform",
  1358. code: "404",
  1359. status: http.StatusNotFound,
  1360. },
  1361. {
  1362. name: "provider image API unsupported shaped error",
  1363. message: "Images API is not supported for this platform",
  1364. code: "404",
  1365. status: http.StatusNotFound,
  1366. },
  1367. {
  1368. name: "provider antigravity whitelist shaped error",
  1369. message: "model claude-3-5-sonnet not in whitelist",
  1370. code: "403",
  1371. status: http.StatusForbidden,
  1372. },
  1373. {
  1374. name: "provider beta policy shaped error",
  1375. message: "beta feature interleaved-thinking-2025-05-14 is not allowed",
  1376. code: "400",
  1377. status: http.StatusBadRequest,
  1378. },
  1379. {
  1380. name: "provider openai fast policy shaped error",
  1381. message: "openai service_tier=priority is not allowed for model gpt-5.5",
  1382. code: "403",
  1383. status: http.StatusForbidden,
  1384. },
  1385. {
  1386. name: "provider codex client policy shaped error",
  1387. message: "This account only allows Codex official clients",
  1388. code: "403",
  1389. status: http.StatusForbidden,
  1390. },
  1391. {
  1392. name: "provider wsv1 unsupported shaped error",
  1393. message: "OpenAI WSv1 is temporarily unsupported. Please enable responses_websockets_v2.",
  1394. code: "400",
  1395. status: http.StatusBadRequest,
  1396. },
  1397. {
  1398. name: "provider passthrough instructions shaped error",
  1399. message: "OpenAI codex passthrough requires a non-empty instructions field",
  1400. code: "403",
  1401. status: http.StatusForbidden,
  1402. },
  1403. }
  1404. for _, tt := range tests {
  1405. t.Run(tt.name, func(t *testing.T) {
  1406. gin.SetMode(gin.TestMode)
  1407. rec := httptest.NewRecorder()
  1408. c, _ := gin.CreateTestContext(rec)
  1409. service.SetOpsUpstreamError(c, tt.status, tt.message, "")
  1410. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
  1411. c,
  1412. "api_error",
  1413. tt.message,
  1414. tt.code,
  1415. tt.status,
  1416. )
  1417. require.Equal(t, "upstream", phase)
  1418. require.False(t, isBusinessLimited)
  1419. require.Equal(t, "provider", errorOwner)
  1420. require.Equal(t, "upstream_http", errorSource)
  1421. })
  1422. }
  1423. }
  1424. func TestClassifyOpsUpstreamNoAvailableTextStillCountsForSLA(t *testing.T) {
  1425. gin.SetMode(gin.TestMode)
  1426. rec := httptest.NewRecorder()
  1427. c, _ := gin.CreateTestContext(rec)
  1428. service.SetOpsUpstreamError(c, http.StatusServiceUnavailable, "No available accounts", "")
  1429. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
  1430. c,
  1431. "api_error",
  1432. "No available accounts",
  1433. "",
  1434. http.StatusServiceUnavailable,
  1435. )
  1436. require.Equal(t, "upstream", phase)
  1437. require.False(t, isBusinessLimited)
  1438. require.Equal(t, "provider", errorOwner)
  1439. require.Equal(t, "upstream_http", errorSource)
  1440. }
  1441. func TestParseOpsErrorResponsePreservesNestedStringCode(t *testing.T) {
  1442. parsed := parseOpsErrorResponse([]byte(`{"error":{"type":"permission_error","code":"GROUP_DELETED","message":"API Key 所属分组已删除"}}`))
  1443. require.Equal(t, "permission_error", parsed.ErrorType)
  1444. require.Equal(t, "GROUP_DELETED", parsed.Code)
  1445. require.Equal(t, "API Key 所属分组已删除", parsed.Message)
  1446. }
  1447. func TestParseOpsErrorResponsePreservesStructuredTopLevelSemantics(t *testing.T) {
  1448. tests := []struct {
  1449. name string
  1450. body string
  1451. wantType string
  1452. wantCode string
  1453. wantMsg string
  1454. }{
  1455. {
  1456. name: "model not found",
  1457. body: `{"type":"model_not_found","code":404,"message":"model unavailable"}`,
  1458. wantType: "model_not_found",
  1459. wantCode: "404",
  1460. wantMsg: "model unavailable",
  1461. },
  1462. {
  1463. name: "string error",
  1464. body: `{"type":"service_unavailable","code":"temporarily_unavailable","error":"capacity exhausted"}`,
  1465. wantType: "service_unavailable",
  1466. wantCode: "temporarily_unavailable",
  1467. wantMsg: "capacity exhausted",
  1468. },
  1469. }
  1470. for _, tt := range tests {
  1471. t.Run(tt.name, func(t *testing.T) {
  1472. parsed := parseOpsErrorResponse([]byte(tt.body))
  1473. require.Equal(t, tt.wantType, normalizeOpsErrorType(parsed.ErrorType, parsed.Code))
  1474. require.Equal(t, tt.wantCode, parsed.Code)
  1475. require.Equal(t, tt.wantMsg, parsed.Message)
  1476. })
  1477. }
  1478. }
  1479. func TestApplyOpsUpstreamFieldsUsesLastNonNilAttempt(t *testing.T) {
  1480. gin.SetMode(gin.TestMode)
  1481. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  1482. service.SetOpsUpstreamError(c, http.StatusUnauthorized, "stale context", "stale detail")
  1483. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  1484. {UpstreamStatusCode: http.StatusTooManyRequests, Message: "first attempt", Detail: "first detail"},
  1485. nil,
  1486. {UpstreamStatusCode: http.StatusServiceUnavailable, Message: "final attempt", Detail: "final detail"},
  1487. nil,
  1488. })
  1489. entry := &service.OpsInsertErrorLogInput{}
  1490. applyOpsUpstreamFieldsFromContext(c, entry)
  1491. require.NotNil(t, entry.UpstreamStatusCode)
  1492. require.Equal(t, http.StatusServiceUnavailable, *entry.UpstreamStatusCode)
  1493. require.NotNil(t, entry.UpstreamErrorMessage)
  1494. require.Equal(t, "final attempt", *entry.UpstreamErrorMessage)
  1495. require.NotNil(t, entry.UpstreamErrorDetail)
  1496. require.Equal(t, "final detail", *entry.UpstreamErrorDetail)
  1497. require.Len(t, entry.UpstreamErrors, 4)
  1498. }
  1499. func TestApplyOpsUpstreamFieldsFinalStatuslessAttemptClearsStaleContext(t *testing.T) {
  1500. gin.SetMode(gin.TestMode)
  1501. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  1502. service.SetOpsUpstreamError(c, http.StatusBadGateway, "stale response", "stale body")
  1503. c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{
  1504. {UpstreamStatusCode: http.StatusBadGateway, Message: "first response"},
  1505. {Kind: "request_error", Message: "final transport failure", Detail: "connection reset"},
  1506. })
  1507. entry := &service.OpsInsertErrorLogInput{}
  1508. applyOpsUpstreamFieldsFromContext(c, entry)
  1509. require.Nil(t, entry.UpstreamStatusCode)
  1510. require.NotNil(t, entry.UpstreamErrorMessage)
  1511. require.Equal(t, "final transport failure", *entry.UpstreamErrorMessage)
  1512. require.NotNil(t, entry.UpstreamErrorDetail)
  1513. require.Equal(t, "connection reset", *entry.UpstreamErrorDetail)
  1514. }
  1515. func TestOpsCaptureWriter_ProtocolLevelTerminalFrameDetection(t *testing.T) {
  1516. state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
  1517. chunks := []string{
  1518. "event : response.failed\r\n",
  1519. "data: { \"response\" : { \"error\" : { \"message\" : \"busy\", \"code\" : \"service_unavailable\" } },",
  1520. " \"type\" : \"response.failed\" }\r\n\r\n",
  1521. }
  1522. for _, chunk := range chunks {
  1523. state.captureResponseChunk([]byte(chunk), http.StatusOK)
  1524. }
  1525. parsed := parseOpsErrorResponse(state.buf.Bytes())
  1526. require.True(t, state.sseCapturing)
  1527. require.True(t, parsed.StreamFailure)
  1528. require.Equal(t, "service_unavailable_error", parsed.ErrorType)
  1529. require.Equal(t, "service_unavailable", parsed.Code)
  1530. require.Equal(t, "busy", parsed.Message)
  1531. require.Equal(t, http.StatusServiceUnavailable, inferStreamFailureStatus(nil, parsed))
  1532. }
  1533. func TestParseOpsSSEFailure_TopLevelErrorsAndUnknownStatus(t *testing.T) {
  1534. tests := []struct {
  1535. name string
  1536. body string
  1537. wantType string
  1538. wantStatus int
  1539. }{
  1540. {
  1541. name: "top-level permission",
  1542. body: "event: error\ndata: {\"message\":\"denied\",\"code\":\"permission_denied\",\"type\":\"error\"}\n\n",
  1543. wantType: "permission_error",
  1544. wantStatus: http.StatusForbidden,
  1545. },
  1546. {
  1547. name: "top-level unavailable",
  1548. body: "data: {\"message\":\"busy\",\"type\":\"error\",\"code\":\"service_unavailable\"}\n\n",
  1549. wantType: "service_unavailable_error",
  1550. wantStatus: http.StatusServiceUnavailable,
  1551. },
  1552. {
  1553. name: "unknown terminal",
  1554. body: "event: response.failed\ndata: {\"type\":\"response.failed\",\"error\":{\"code\":\"new_provider_code\",\"message\":\"failed\"}}\n\n",
  1555. wantType: "upstream_error",
  1556. wantStatus: http.StatusBadGateway,
  1557. },
  1558. {
  1559. name: "explicit terminal status",
  1560. body: "event: error\ndata: {\"type\":\"error\",\"status_code\":429,\"code\":\"new_rate_code\",\"message\":\"slow down\"}\n\n",
  1561. wantType: "api_error",
  1562. wantStatus: http.StatusTooManyRequests,
  1563. },
  1564. }
  1565. for _, tt := range tests {
  1566. t.Run(tt.name, func(t *testing.T) {
  1567. parsed := parseOpsErrorResponse([]byte(tt.body))
  1568. require.True(t, parsed.StreamFailure)
  1569. require.Equal(t, tt.wantType, parsed.ErrorType)
  1570. gin.SetMode(gin.TestMode)
  1571. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  1572. service.SetOpsUpstreamError(c, http.StatusUnauthorized, "old attempt", "")
  1573. require.Equal(t, tt.wantStatus, inferStreamFailureStatus(c, parsed), "terminal status must not inherit an earlier attempt")
  1574. })
  1575. }
  1576. }
  1577. func TestOpsCaptureWriter_OversizedNonTerminalFrameRemainsBounded(t *testing.T) {
  1578. state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
  1579. state.captureResponseChunk([]byte("data: "+strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2)+"\n\n"), http.StatusOK)
  1580. require.Empty(t, state.buf.Bytes())
  1581. require.LessOrEqual(t, cap(state.probe), opsTerminalSSEFrameProbeLimit)
  1582. state.captureResponseChunk([]byte("event: error\ndata: {\"type\":\"error\",\"code\":\"permission_denied\",\"message\":\"denied\"}\n\n"), http.StatusOK)
  1583. require.True(t, state.sseCapturing)
  1584. require.NotEmpty(t, state.buf.Bytes())
  1585. }
  1586. func TestOpsCaptureWriter_TerminalMetadataSurvivesBodyCaptureTruncation(t *testing.T) {
  1587. state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
  1588. frame := "event: response.failed\ndata: {\"padding\":\"" + strings.Repeat("x", opsCaptureWriterLimit) + "\",\"type\":\"response.failed\",\"error\":{\"code\":\"service_unavailable\",\"message\":\"busy\"}}\n\n"
  1589. state.captureResponseChunk([]byte(frame), http.StatusOK)
  1590. state.finalizeResponseCapture()
  1591. require.Len(t, state.buf.Bytes(), opsCaptureWriterLimit)
  1592. require.True(t, parseOpsErrorResponse(state.buf.Bytes()).StreamFailure, "the bounded parser must fail closed from the terminal event line")
  1593. require.True(t, state.terminalFound)
  1594. require.Equal(t, "service_unavailable_error", state.terminalError.ErrorType)
  1595. require.Equal(t, "busy", state.terminalError.Message)
  1596. }
  1597. func TestOpsErrorLoggerMiddleware_LargeTerminalFrameUsesEventFallback(t *testing.T) {
  1598. setupOpsErrorLogTestQueue(t, 2)
  1599. gin.SetMode(gin.TestMode)
  1600. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  1601. router := gin.New()
  1602. router.Use(OpsErrorLoggerMiddleware(ops))
  1603. router.POST("/v1/responses", func(c *gin.Context) {
  1604. c.Status(http.StatusOK)
  1605. _, _ = c.Writer.WriteString("event: response.failed\n")
  1606. _, _ = c.Writer.WriteString("data: {\"authorization\":\"Bearer must-not-persist\",\"padding\":\"" + strings.Repeat("x", opsTerminalSSEFrameProbeLimit*2) + "\"}")
  1607. })
  1608. recorder := httptest.NewRecorder()
  1609. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  1610. require.Equal(t, http.StatusOK, recorder.Code)
  1611. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  1612. job := <-opsErrorLogQueue
  1613. require.Equal(t, http.StatusBadGateway, job.entry.StatusCode)
  1614. require.Equal(t, "upstream_error", job.entry.ErrorType)
  1615. require.Equal(t, "upstream stream failed", job.entry.ErrorMessage)
  1616. require.NotContains(t, job.entry.ErrorMessage, "must-not-persist")
  1617. require.NotContains(t, job.entry.ErrorBody, "must-not-persist")
  1618. require.Contains(t, job.entry.ErrorBody, `"payload_truncated":true`)
  1619. }
  1620. func TestOpsErrorLoggerMiddleware_DetectsTerminalDataAtEOFWithoutBlankLine(t *testing.T) {
  1621. setupOpsErrorLogTestQueue(t, 2)
  1622. gin.SetMode(gin.TestMode)
  1623. ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
  1624. router := gin.New()
  1625. router.Use(OpsErrorLoggerMiddleware(ops))
  1626. router.POST("/v1/responses", func(c *gin.Context) {
  1627. c.Status(http.StatusOK)
  1628. _, _ = c.Writer.WriteString(`data: {"message":"denied","code":"permission_denied","type":"error"}`)
  1629. })
  1630. recorder := httptest.NewRecorder()
  1631. router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/responses", nil))
  1632. require.Equal(t, http.StatusOK, recorder.Code)
  1633. require.Equal(t, int64(1), OpsErrorLogQueueLength())
  1634. job := <-opsErrorLogQueue
  1635. require.Equal(t, http.StatusForbidden, job.entry.StatusCode)
  1636. require.Equal(t, "permission_error", job.entry.ErrorType)
  1637. require.Equal(t, "denied", job.entry.ErrorMessage)
  1638. }
  1639. func TestOpsCaptureWriter_DetectsCROnlySSEFrame(t *testing.T) {
  1640. state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
  1641. state.captureResponseChunk([]byte("data: {\"type\":\"error\",\"code\":\"service_unavailable\",\"message\":\"busy\"}\r\r"), http.StatusOK)
  1642. require.True(t, state.sseCapturing)
  1643. parsed := parseOpsErrorResponse(state.buf.Bytes())
  1644. require.True(t, parsed.StreamFailure)
  1645. require.Equal(t, "service_unavailable_error", parsed.ErrorType)
  1646. }
  1647. func TestSanitizeOpsSSEDataForPersistence_RedactsJSONFields(t *testing.T) {
  1648. body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer secret\",\ndata: \"nested\":{\"api_key\":\"sk-secret\"}}\n\n")
  1649. sanitized := sanitizeOpsSSEDataForPersistence(body)
  1650. require.NotContains(t, sanitized, "Bearer secret")
  1651. require.NotContains(t, sanitized, "sk-secret")
  1652. require.Contains(t, sanitized, `"authorization":"[REDACTED]"`)
  1653. require.Contains(t, sanitized, `"api_key":"[REDACTED]"`)
  1654. }
  1655. func TestSanitizeOpsSSEDataForPersistence_DropsTruncatedJSONFragment(t *testing.T) {
  1656. body := []byte("event: error\ndata: {\"type\":\"error\",\"authorization\":\"Bearer leaked")
  1657. sanitized := sanitizeOpsSSEDataForPersistence(body)
  1658. require.NotContains(t, sanitized, "Bearer leaked")
  1659. require.Contains(t, sanitized, `data: {"payload_truncated":true}`)
  1660. }
  1661. func BenchmarkOpsCaptureWriterSuccessfulSSEFrames(b *testing.B) {
  1662. frame := []byte("event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n")
  1663. state := &opsCaptureWriterState{limit: opsCaptureWriterLimit}
  1664. b.ReportAllocs()
  1665. for i := 0; i < b.N; i++ {
  1666. state.captureResponseChunk(frame, http.StatusOK)
  1667. }
  1668. if state.buf.Len() != 0 {
  1669. b.Fatal("successful frames must not be captured")
  1670. }
  1671. }
  1672. func TestSetOpsEndpointContext_SetsContextKeys(t *testing.T) {
  1673. gin.SetMode(gin.TestMode)
  1674. rec := httptest.NewRecorder()
  1675. c, _ := gin.CreateTestContext(rec)
  1676. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1677. setOpsEndpointContext(c, "claude-3-5-sonnet-20241022", int16(2)) // stream
  1678. v, ok := c.Get(opsUpstreamModelKey)
  1679. require.True(t, ok)
  1680. vStr, ok := v.(string)
  1681. require.True(t, ok)
  1682. require.Equal(t, "claude-3-5-sonnet-20241022", vStr)
  1683. rt, ok := c.Get(opsRequestTypeKey)
  1684. require.True(t, ok)
  1685. rtVal, ok := rt.(int16)
  1686. require.True(t, ok)
  1687. require.Equal(t, int16(2), rtVal)
  1688. }
  1689. func TestSetOpsEndpointContext_EmptyModelNotStored(t *testing.T) {
  1690. gin.SetMode(gin.TestMode)
  1691. rec := httptest.NewRecorder()
  1692. c, _ := gin.CreateTestContext(rec)
  1693. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1694. setOpsEndpointContext(c, "", int16(1))
  1695. _, ok := c.Get(opsUpstreamModelKey)
  1696. require.False(t, ok, "empty upstream model should not be stored")
  1697. rt, ok := c.Get(opsRequestTypeKey)
  1698. require.True(t, ok)
  1699. rtVal, ok := rt.(int16)
  1700. require.True(t, ok)
  1701. require.Equal(t, int16(1), rtVal)
  1702. }
  1703. func TestSetOpsEndpointContext_NilContext(t *testing.T) {
  1704. require.NotPanics(t, func() {
  1705. setOpsEndpointContext(nil, "model", int16(1))
  1706. })
  1707. }
  1708. func TestGetOpsAPIKeyFallsBackToOpsFallbackKey(t *testing.T) {
  1709. gin.SetMode(gin.TestMode)
  1710. rec := httptest.NewRecorder()
  1711. c, _ := gin.CreateTestContext(rec)
  1712. // 主 key 缺席(鉴权早退场景):返回 nil。
  1713. require.Nil(t, getOpsAPIKey(c))
  1714. // 写入 ops 专用 fallback key 后应能取到,且带齐 user/group。
  1715. groupID := int64(55)
  1716. apiKey := &service.APIKey{
  1717. ID: 100,
  1718. GroupID: &groupID,
  1719. User: &service.User{ID: 7},
  1720. Group: &service.Group{ID: groupID, Platform: service.PlatformAnthropic},
  1721. }
  1722. c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), apiKey)
  1723. got := getOpsAPIKey(c)
  1724. require.NotNil(t, got)
  1725. require.Equal(t, int64(100), got.ID)
  1726. require.NotNil(t, got.User)
  1727. require.Equal(t, int64(7), got.User.ID)
  1728. require.NotNil(t, got.Group)
  1729. require.Equal(t, service.PlatformAnthropic, got.Group.Platform)
  1730. }
  1731. func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) {
  1732. gin.SetMode(gin.TestMode)
  1733. rec := httptest.NewRecorder()
  1734. c, _ := gin.CreateTestContext(rec)
  1735. primary := &service.APIKey{ID: 1}
  1736. fallback := &service.APIKey{ID: 2}
  1737. c.Set(string(middleware2.ContextKeyAPIKey), primary)
  1738. c.Set(string(middleware2.ContextKeyOpsFallbackAPIKey), fallback)
  1739. got := getOpsAPIKey(c)
  1740. require.NotNil(t, got)
  1741. require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key")
  1742. }
  1743. // 分组模型白名单入口拒绝:业务限流原因复用 local_model_configuration,但
  1744. // 携带 ingress 拒绝原因 model_not_allowed,阶段应保持自然分类(request /
  1745. // client),不落到 routing。
  1746. func TestClassifyOpsIngressModelNotAllowedKeepsRequestPhase(t *testing.T) {
  1747. gin.SetMode(gin.TestMode)
  1748. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  1749. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  1750. middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
  1751. phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
  1752. c,
  1753. "not_found_error",
  1754. `Model "gpt-4.1" is not available for this group`,
  1755. "model_not_found",
  1756. http.StatusNotFound,
  1757. )
  1758. require.Equal(t, "request", phase)
  1759. require.True(t, isBusinessLimited)
  1760. require.Equal(t, "client", errorOwner)
  1761. require.Equal(t, "client_request", errorSource)
  1762. }
  1763. // 调度阶段的账号模型映射拒绝(无 ingress 标记)仍归类为 routing。
  1764. func TestClassifyOpsLocalModelConfigurationWithoutIngressMarkStaysRouting(t *testing.T) {
  1765. gin.SetMode(gin.TestMode)
  1766. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  1767. service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
  1768. phase, isBusinessLimited, errorOwner, _ := classifyOpsErrorLog(
  1769. c,
  1770. "model_not_found",
  1771. "Model \"gpt-missing\" is not supported by any configured account in this group",
  1772. "",
  1773. http.StatusNotFound,
  1774. )
  1775. require.Equal(t, "routing", phase)
  1776. require.True(t, isBusinessLimited)
  1777. require.Equal(t, "platform", errorOwner)
  1778. }