openai_gateway_upstream_errors.go 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "strings"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  13. "github.com/gin-gonic/gin"
  14. "github.com/tidwall/gjson"
  15. "go.uber.org/zap"
  16. )
  17. func logOpenAIInstructionsRequiredDebug(
  18. ctx context.Context,
  19. c *gin.Context,
  20. account *Account,
  21. upstreamStatusCode int,
  22. upstreamMsg string,
  23. requestBody []byte,
  24. upstreamBody []byte,
  25. ) {
  26. msg := strings.TrimSpace(upstreamMsg)
  27. if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) {
  28. return
  29. }
  30. if ctx == nil {
  31. ctx = context.Background()
  32. }
  33. accountID := int64(0)
  34. accountName := ""
  35. if account != nil {
  36. accountID = account.ID
  37. accountName = strings.TrimSpace(account.Name)
  38. }
  39. userAgent := ""
  40. originator := ""
  41. if c != nil {
  42. userAgent = strings.TrimSpace(c.GetHeader("User-Agent"))
  43. originator = strings.TrimSpace(c.GetHeader("originator"))
  44. }
  45. fields := []zap.Field{
  46. zap.String("component", "service.openai_gateway"),
  47. zap.Int64("account_id", accountID),
  48. zap.String("account_name", accountName),
  49. zap.Int("upstream_status_code", upstreamStatusCode),
  50. zap.String("upstream_error_message", msg),
  51. zap.String("request_user_agent", userAgent),
  52. zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)),
  53. }
  54. fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody)
  55. logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查")
  56. }
  57. func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
  58. if upstreamStatusCode != http.StatusBadRequest {
  59. return false
  60. }
  61. hasInstructionRequired := func(text string) bool {
  62. lower := strings.ToLower(strings.TrimSpace(text))
  63. if lower == "" {
  64. return false
  65. }
  66. if strings.Contains(lower, "instructions are required") {
  67. return true
  68. }
  69. if strings.Contains(lower, "required parameter: 'instructions'") {
  70. return true
  71. }
  72. if strings.Contains(lower, "required parameter: instructions") {
  73. return true
  74. }
  75. if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") {
  76. return true
  77. }
  78. return strings.Contains(lower, "instruction") && strings.Contains(lower, "required")
  79. }
  80. if hasInstructionRequired(upstreamMsg) {
  81. return true
  82. }
  83. if len(upstreamBody) == 0 {
  84. return false
  85. }
  86. errMsg := gjson.GetBytes(upstreamBody, "error.message").String()
  87. errMsgLower := strings.ToLower(strings.TrimSpace(errMsg))
  88. errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String()))
  89. errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String()))
  90. errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String()))
  91. if errParam == "instructions" {
  92. return true
  93. }
  94. if hasInstructionRequired(errMsg) {
  95. return true
  96. }
  97. if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") {
  98. return true
  99. }
  100. if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") {
  101. return true
  102. }
  103. return false
  104. }
  105. func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
  106. if upstreamStatusCode < http.StatusBadRequest {
  107. return false
  108. }
  109. hasOpenAIServerOverloadedCode := func(payload []byte) bool {
  110. code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
  111. if code == "" {
  112. code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
  113. }
  114. return code == "server_is_overloaded" || code == "slow_down"
  115. }
  116. if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) {
  117. return true
  118. }
  119. if isOpenAICapacityShedMessage(upstreamMsg) ||
  120. isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "error.message").String()) ||
  121. isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "response.error.message").String()) ||
  122. (!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody))) {
  123. return true
  124. }
  125. if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable {
  126. return false
  127. }
  128. if upstreamStatusCode != http.StatusBadRequest {
  129. return false
  130. }
  131. match := func(text string) bool {
  132. lower := strings.ToLower(strings.TrimSpace(text))
  133. if lower == "" {
  134. return false
  135. }
  136. if strings.Contains(lower, "an error occurred while processing your request") {
  137. return true
  138. }
  139. if strings.Contains(lower, "selected model is at capacity") {
  140. return true
  141. }
  142. return strings.Contains(lower, "you can retry your request") &&
  143. strings.Contains(lower, "help.openai.com") &&
  144. strings.Contains(lower, "request id")
  145. }
  146. if match(upstreamMsg) {
  147. return true
  148. }
  149. if len(upstreamBody) == 0 {
  150. return false
  151. }
  152. if match(gjson.GetBytes(upstreamBody, "error.message").String()) {
  153. return true
  154. }
  155. if match(gjson.GetBytes(upstreamBody, "response.error.message").String()) ||
  156. match(gjson.GetBytes(upstreamBody, "message").String()) {
  157. return true
  158. }
  159. // A valid JSON error may echo arbitrary request content. Only its explicit
  160. // error fields are authoritative; scan the whole body only for non-JSON
  161. // providers that return a plain-text error response.
  162. return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody))
  163. }
  164. func isOpenAICapacityShedMessage(text string) bool {
  165. lower := strings.ToLower(strings.TrimSpace(text))
  166. return strings.Contains(lower, "server is overloaded") ||
  167. strings.Contains(lower, "servers are overloaded") ||
  168. strings.Contains(lower, "servers are currently overloaded")
  169. }
  170. func isOpenAIRequestScopedCapacityShed(upstreamMsg string, upstreamBody []byte) bool {
  171. return isOpenAIUpstreamCapacityShedEvent(upstreamBody) ||
  172. isOpenAICapacityShedMessage(upstreamMsg) ||
  173. (!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody)))
  174. }
  175. func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool {
  176. match := func(text string) bool {
  177. lower := strings.ToLower(strings.TrimSpace(text))
  178. if lower == "" {
  179. return false
  180. }
  181. if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") {
  182. return true
  183. }
  184. if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") {
  185. return true
  186. }
  187. hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long")
  188. if strings.Contains(lower, "context window") && hasExceeded {
  189. return true
  190. }
  191. if strings.Contains(lower, "context length") && hasExceeded {
  192. return true
  193. }
  194. return strings.Contains(lower, "token limit") &&
  195. strings.Contains(lower, "context") &&
  196. hasExceeded
  197. }
  198. if match(upstreamMsg) {
  199. return true
  200. }
  201. if len(upstreamBody) == 0 {
  202. return false
  203. }
  204. for _, path := range []string{
  205. "error.message",
  206. "response.error.message",
  207. "message",
  208. "error.code",
  209. "response.error.code",
  210. "code",
  211. } {
  212. if match(gjson.GetBytes(upstreamBody, path).String()) {
  213. return true
  214. }
  215. }
  216. // Do not let echoed request content in a structured JSON error change the
  217. // retry/client-status classification. Plain-text upstream errors remain
  218. // supported by scanning the whole body only when it is not valid JSON.
  219. return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody))
  220. }
  221. func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool {
  222. switch statusCode {
  223. case 401, 402, 403, 405, 429, 529:
  224. return true
  225. default:
  226. return statusCode >= 500
  227. }
  228. }
  229. func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(account *Account, statusCode int, upstreamMsg string, upstreamBody []byte) bool {
  230. // cyber_policy is request-scoped even when an intermediary wraps the
  231. // provider response in a retryable 5xx status. Never punish or rotate the
  232. // selected credential for it.
  233. if hit, _, _ := detectOpenAICyberPolicy(upstreamBody); hit {
  234. return false
  235. }
  236. if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
  237. return false
  238. }
  239. if isOpenAIHTTPUpstreamAccessStateError(statusCode, upstreamMsg, upstreamBody) {
  240. return true
  241. }
  242. if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) {
  243. return true
  244. }
  245. // A missing model is account/provider availability, not a malformed client
  246. // request. Keep this unconditional exception inside the OpenAI-compatible
  247. // gateway and require an eligible account so Anthropic/Gemini paths retain
  248. // their existing opt-in 400 behavior.
  249. // A bare forwarding service has no account-selection owner to consume a
  250. // failover sentinel. In that mode (used by direct/single-account callers),
  251. // preserve the deterministic upstream 400 instead of returning an unwritten
  252. // retry signal. Managed gateway instances always have an account repository;
  253. // their handler can exclude this account and actually select another one.
  254. if s != nil && s.accountRepo != nil && account != nil && account.IsOpenAICompatible() && statusCode == http.StatusBadRequest &&
  255. isOpenAICompatibleModelNotFound400(upstreamBody) {
  256. return true
  257. }
  258. if s.shouldFailoverUpstreamError(statusCode) {
  259. return true
  260. }
  261. return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
  262. }
  263. func isOpenAICompatibleModelNotFound400(respBody []byte) bool {
  264. code := strings.TrimSpace(extractUpstreamErrorCode(respBody))
  265. if code != "" {
  266. return strings.EqualFold(code, "model_not_found")
  267. }
  268. msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
  269. if msg == "" && !gjson.ValidBytes(respBody) {
  270. msg = strings.ToLower(strings.TrimSpace(string(respBody)))
  271. }
  272. return strings.Contains(msg, "unknown provider for model") ||
  273. strings.Contains(msg, "model not found") ||
  274. strings.Contains(msg, "model is not supported")
  275. }
  276. // IsOpenAICompatibleModelNotFound400 reports whether an OpenAI-compatible 400
  277. // is an account-specific missing-model response eligible for failover.
  278. func IsOpenAICompatibleModelNotFound400(respBody []byte) bool {
  279. return isOpenAICompatibleModelNotFound400(respBody)
  280. }
  281. // OpenAIRequestBodyTooLargeClientMessage is the fixed downstream message used
  282. // after all account-specific request body limit failovers are exhausted.
  283. const OpenAIRequestBodyTooLargeClientMessage = "Request payload is too large"
  284. const openAIRequestBodyTooLargeReason = GatewayFailureReason("openai_request_body_too_large")
  285. func isOpenAIRequestBodyTooLargeError(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
  286. return statusCode == http.StatusRequestEntityTooLarge && !isOpenAIContextWindowError(upstreamMsg, upstreamBody)
  287. }
  288. func newOpenAIUpstreamFailoverError(
  289. statusCode int,
  290. responseHeaders http.Header,
  291. responseBody []byte,
  292. upstreamMsg string,
  293. retryableOnSameAccount bool,
  294. ) *UpstreamFailoverError {
  295. requestScopedCapacity := isOpenAIRequestScopedCapacityShed(upstreamMsg, responseBody)
  296. failoverErr := &UpstreamFailoverError{
  297. StatusCode: statusCode,
  298. ResponseBody: responseBody,
  299. ResponseHeaders: responseHeaders.Clone(),
  300. RetryableOnSameAccount: retryableOnSameAccount || requestScopedCapacity,
  301. RequestScopedTransient: requestScopedCapacity,
  302. }
  303. if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, responseBody) {
  304. failoverErr.RetryableOnSameAccount = false
  305. failoverErr.RequestScopedTransient = false
  306. failoverErr.Scope = GatewayFailureScopeAccount
  307. failoverErr.Reason = openAIRequestBodyTooLargeReason
  308. failoverErr.NextAccountAction = NextAccountRetry
  309. failoverErr.ClientStatusCode = http.StatusRequestEntityTooLarge
  310. failoverErr.ClientMessage = OpenAIRequestBodyTooLargeClientMessage
  311. }
  312. if isOpenAIHTTPUpstreamAccessStateError(statusCode, upstreamMsg, responseBody) {
  313. failoverErr.RetryableOnSameAccount = false
  314. failoverErr.RequestScopedTransient = false
  315. failoverErr.Stage = GatewayFailureStageAccountAuth
  316. failoverErr.Scope = GatewayFailureScopeAccount
  317. failoverErr.Reason = OpenAIUpstreamAccessStateReason
  318. failoverErr.NextAccountAction = NextAccountRetry
  319. failoverErr.ClientStatusCode = http.StatusBadGateway
  320. failoverErr.ClientMessage = openAIUpstreamAccessUnavailableClientMessage
  321. } else if requestScopedCapacity {
  322. // Preserve the provider's actionable overload message after gateway
  323. // retries are exhausted, but expose it as a retryable server_error.
  324. failoverErr.ClientStatusCode = http.StatusServiceUnavailable
  325. failoverErr.ClientMessage = openAICapacityShedClientMessage(upstreamMsg, responseBody)
  326. }
  327. return failoverErr
  328. }
  329. func (s *OpenAIGatewayService) newOpenAIAccountFailoverError(
  330. account *Account,
  331. statusCode int,
  332. responseHeaders http.Header,
  333. responseBody []byte,
  334. upstreamMsg string,
  335. shouldDisable bool,
  336. retryableOnSameAccount bool,
  337. ) *UpstreamFailoverError {
  338. return s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, responseHeaders, responseHeaders, responseBody, upstreamMsg, shouldDisable, retryableOnSameAccount)
  339. }
  340. func (s *OpenAIGatewayService) newOpenAIAccountFailoverErrorWithClassificationHeaders(
  341. account *Account,
  342. statusCode int,
  343. responseHeaders http.Header,
  344. classificationHeaders http.Header,
  345. responseBody []byte,
  346. upstreamMsg string,
  347. shouldDisable bool,
  348. retryableOnSameAccount bool,
  349. ) *UpstreamFailoverError {
  350. oauth429Retry := s.shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account, statusCode, shouldDisable, classificationHeaders, responseBody)
  351. failoverErr := newOpenAIUpstreamFailoverError(
  352. statusCode,
  353. responseHeaders,
  354. responseBody,
  355. upstreamMsg,
  356. retryableOnSameAccount || oauth429Retry,
  357. )
  358. if oauth429Retry {
  359. failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account)
  360. failoverErr.SameAccountRetryDelay = openAIOAuth429SameAccountRetryDelay(responseHeaders, failoverErr.SameAccountRetryDeadline)
  361. failoverErr.SameAccountRetryMax = openAIOAuth429MaxSameAccountRetries
  362. }
  363. return failoverErr
  364. }
  365. const (
  366. openAIUpstreamAccessUnavailableClientMessage = "Upstream access is temporarily unavailable, please retry later"
  367. // OpenAIUpstreamAccessStateReason marks a provider credential whose
  368. // account, workspace, or organization is unavailable.
  369. OpenAIUpstreamAccessStateReason = GatewayFailureReason("openai_upstream_access_state")
  370. // OpenAIHTTPContinuationUnsupportedReason identifies accounts that cannot
  371. // preserve an official Responses HTTP continuation without dropping state.
  372. OpenAIHTTPContinuationUnsupportedReason = GatewayFailureReason("openai_http_continuation_unsupported")
  373. )
  374. // isOpenAIUpstreamAccessStateError recognizes provider-side credential state
  375. // failures only from explicit structured codes. Free-form messages may contain
  376. // echoed user input, including inside stream terminal error.message fields.
  377. func isOpenAIUpstreamAccessStateError(_ string, body []byte) bool {
  378. if len(body) == 0 || !gjson.ValidBytes(body) {
  379. return false
  380. }
  381. for _, path := range []string{"error.code", "response.error.code", "detail.code", "code"} {
  382. if isOpenAIUpstreamAccessStateCode(gjson.GetBytes(body, path).String()) {
  383. return true
  384. }
  385. }
  386. return false
  387. }
  388. func isOpenAIUpstreamAccessStateCode(value string) bool {
  389. value = strings.ToLower(strings.TrimSpace(value))
  390. if value == "deactivated_workspace" {
  391. return true
  392. }
  393. for _, subject := range []string{"workspace", "account", "organization", "org"} {
  394. for _, state := range []string{"deactivated", "disabled", "suspended"} {
  395. if value == subject+"_"+state || value == state+"_"+subject {
  396. return true
  397. }
  398. }
  399. }
  400. return false
  401. }
  402. // isOpenAIHTTPUpstreamAccessStateError is deliberately status-independent:
  403. // known provider codes are durable evidence, while 401/403 messages without
  404. // such a code must flow through the existing authentication/403 policies.
  405. func isOpenAIHTTPUpstreamAccessStateError(_ int, _ string, body []byte) bool {
  406. return isOpenAIUpstreamAccessStateError("", body)
  407. }
  408. func openAICapacityShedClientMessage(upstreamMsg string, body []byte) string {
  409. for _, candidate := range []string{
  410. upstreamMsg,
  411. gjson.GetBytes(body, "error.message").String(),
  412. gjson.GetBytes(body, "response.error.message").String(),
  413. gjson.GetBytes(body, "message").String(),
  414. } {
  415. candidate = sanitizeUpstreamErrorMessage(strings.TrimSpace(candidate))
  416. if candidate != "" && isOpenAICapacityShedMessage(candidate) {
  417. return candidate
  418. }
  419. }
  420. return "Upstream service is temporarily overloaded, please retry later"
  421. }
  422. // IsOpenAIRequestBodyTooLarge reports whether another account may accept the
  423. // same request even though the selected account rejected its serialized size.
  424. func (e *UpstreamFailoverError) IsOpenAIRequestBodyTooLarge() bool {
  425. return e != nil && e.Reason == openAIRequestBodyTooLargeReason
  426. }
  427. // IsOpenAICapacityShed reports whether typed client fields were derived from a
  428. // recognized provider overload rather than supplied by an unrelated failure.
  429. func (e *UpstreamFailoverError) IsOpenAICapacityShed() bool {
  430. return e != nil && e.RequestScopedTransient && isOpenAIRequestScopedCapacityShed("", e.ResponseBody)
  431. }
  432. func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
  433. var buf bytes.Buffer
  434. enc := json.NewEncoder(&buf)
  435. enc.SetEscapeHTML(false)
  436. if err := enc.Encode(v); err != nil {
  437. return nil, err
  438. }
  439. out := buf.Bytes()
  440. if len(out) > 0 && out[len(out)-1] == '\n' {
  441. out = out[:len(out)-1]
  442. }
  443. return out, nil
  444. }
  445. func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 {
  446. limit := openAIUpstreamErrorBodyReadLimit
  447. if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) {
  448. limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
  449. }
  450. return limit
  451. }
  452. func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte {
  453. if resp == nil || resp.Body == nil {
  454. return nil
  455. }
  456. cfg := (*config.Config)(nil)
  457. if s != nil {
  458. cfg = s.cfg
  459. }
  460. body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg)))
  461. return body
  462. }
  463. func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, canonicalModel ...string) bool {
  464. if len(canonicalModel) > 0 {
  465. return s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, canonicalModel[0])
  466. }
  467. return s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody)
  468. }
  469. func (s *OpenAIGatewayService) handleErrorResponse(
  470. ctx context.Context,
  471. resp *http.Response,
  472. c *gin.Context,
  473. account *Account,
  474. requestBody []byte,
  475. requestedModel ...string,
  476. ) (*OpenAIForwardResult, error) {
  477. body := s.readUpstreamErrorBody(resp)
  478. body = s.redactAgentIdentitySensitiveBody(ctx, account, body)
  479. // cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。
  480. // 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover
  481. // (shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。
  482. if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
  483. MarkOpsCyberPolicy(c, CyberPolicyMark{
  484. Code: code,
  485. Message: cyberMsg,
  486. Body: truncateString(string(body), 4096),
  487. UpstreamStatus: resp.StatusCode,
  488. })
  489. setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
  490. writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  491. contentType := resp.Header.Get("Content-Type")
  492. if contentType == "" {
  493. contentType = "application/json"
  494. }
  495. c.Data(resp.StatusCode, contentType, body)
  496. if cyberMsg == "" {
  497. return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
  498. }
  499. return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
  500. }
  501. if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(resp.StatusCode, body) {
  502. clientMsg := grokContentPolicyClientMessage(body)
  503. setOpsUpstreamError(c, resp.StatusCode, clientMsg, truncateString(string(body), 2048))
  504. writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  505. MarkResponseCommitted(c)
  506. c.JSON(http.StatusForbidden, gin.H{
  507. "error": gin.H{
  508. "type": "invalid_request_error",
  509. "message": clientMsg,
  510. },
  511. })
  512. return nil, fmt.Errorf("grok content policy rejection: %s", clientMsg)
  513. }
  514. upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
  515. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  516. upstreamDetail := ""
  517. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  518. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  519. if maxBytes <= 0 {
  520. maxBytes = 2048
  521. }
  522. upstreamDetail = truncateString(string(body), maxBytes)
  523. }
  524. setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
  525. logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
  526. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  527. logger.LegacyPrintf("service.openai_gateway",
  528. "OpenAI upstream error %d (account=%d platform=%s type=%s): %s",
  529. resp.StatusCode,
  530. account.ID,
  531. account.Platform,
  532. account.Type,
  533. truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
  534. )
  535. }
  536. if isOpenAIRequestBodyTooLargeError(resp.StatusCode, upstreamMsg, body) {
  537. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  538. ProxyID: opsUpstreamProxyID(account),
  539. ProxyName: opsUpstreamProxyName(account),
  540. Platform: account.Platform,
  541. AccountID: account.ID,
  542. AccountName: account.Name,
  543. UpstreamStatusCode: resp.StatusCode,
  544. UpstreamRequestID: resp.Header.Get("x-request-id"),
  545. Kind: "failover",
  546. Message: upstreamMsg,
  547. Detail: upstreamDetail,
  548. })
  549. s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel...)
  550. return nil, newOpenAIUpstreamFailoverError(
  551. resp.StatusCode,
  552. resp.Header,
  553. body,
  554. upstreamMsg,
  555. false,
  556. )
  557. }
  558. if status, errType, errMsg, matched := applyErrorPassthroughRule(
  559. c,
  560. PlatformOpenAI,
  561. resp.StatusCode,
  562. body,
  563. http.StatusBadGateway,
  564. "upstream_error",
  565. "Upstream request failed",
  566. ); matched {
  567. MarkResponseCommitted(c)
  568. c.JSON(status, gin.H{
  569. "error": gin.H{
  570. "type": errType,
  571. "message": errMsg,
  572. },
  573. })
  574. if upstreamMsg == "" {
  575. upstreamMsg = errMsg
  576. }
  577. if upstreamMsg == "" {
  578. return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
  579. }
  580. return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
  581. }
  582. // Check custom error codes
  583. if !account.ShouldHandleErrorCode(resp.StatusCode) {
  584. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  585. ProxyID: opsUpstreamProxyID(account),
  586. ProxyName: opsUpstreamProxyName(account),
  587. Platform: account.Platform,
  588. AccountID: account.ID,
  589. AccountName: account.Name,
  590. UpstreamStatusCode: resp.StatusCode,
  591. UpstreamRequestID: resp.Header.Get("x-request-id"),
  592. Kind: "http_error",
  593. Message: upstreamMsg,
  594. Detail: upstreamDetail,
  595. })
  596. MarkResponseCommitted(c)
  597. c.JSON(http.StatusInternalServerError, gin.H{
  598. "error": gin.H{
  599. "type": "upstream_error",
  600. "message": "Upstream gateway error",
  601. },
  602. })
  603. if upstreamMsg == "" {
  604. return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
  605. }
  606. return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
  607. }
  608. // Handle upstream error (mark account status)
  609. var reqModel string
  610. if len(requestedModel) > 0 {
  611. reqModel = strings.TrimSpace(requestedModel[0])
  612. }
  613. if reqModel == "" {
  614. reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody)
  615. reqModel = canonicalOpenAIAccountSchedulingModel(account, reqModel)
  616. }
  617. shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
  618. kind := "http_error"
  619. if shouldDisable {
  620. kind = "failover"
  621. }
  622. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  623. ProxyID: opsUpstreamProxyID(account),
  624. ProxyName: opsUpstreamProxyName(account),
  625. Platform: account.Platform,
  626. AccountID: account.ID,
  627. AccountName: account.Name,
  628. UpstreamStatusCode: resp.StatusCode,
  629. UpstreamRequestID: resp.Header.Get("x-request-id"),
  630. Kind: kind,
  631. Message: upstreamMsg,
  632. Detail: upstreamDetail,
  633. })
  634. if shouldDisable {
  635. return nil, &UpstreamFailoverError{
  636. StatusCode: resp.StatusCode,
  637. ResponseBody: body,
  638. RetryableOnSameAccount: false,
  639. }
  640. }
  641. MarkResponseCommitted(c)
  642. // 上游 400 是确定性的请求错误:同一份请求体换账号、重试多少次都会失败。归一成
  643. // 502 upstream_error 会让下游网关把它当成可重试的上游故障反复重放(#5479 实测
  644. // 30 个失败请求被放大成 60 次上游调用),同时抹掉客户端定位问题所需的 code/param。
  645. //
  646. // 走到这里说明 shouldFailoverOpenAIUpstreamResponse 已判定该 400 不可 failover,
  647. // 即 server_is_overloaded / at capacity 这类可重试的 400 不会到达此处。
  648. //
  649. // 兄弟路径早已这么做:handleCompatErrorResponse(ChatCompletions / Anthropic)
  650. // 回真实状态码 + invalid_request_error + 真实 message;/v1/images 还额外透传
  651. // code/param。原生 Responses 是唯一漏掉的一条。
  652. if isOpenAIDeterministicClientError(resp.StatusCode) {
  653. writeOpenAIUpstreamClientError(c, resp.StatusCode, body, upstreamMsg)
  654. if upstreamMsg == "" {
  655. return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
  656. }
  657. return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
  658. }
  659. // Return appropriate error response
  660. var errType, errMsg string
  661. var statusCode int
  662. switch resp.StatusCode {
  663. case 401:
  664. statusCode = http.StatusBadGateway
  665. errType = "upstream_error"
  666. errMsg = "Upstream authentication failed, please contact administrator"
  667. case 402:
  668. statusCode = http.StatusBadGateway
  669. errType = "upstream_error"
  670. errMsg = "Upstream payment required: insufficient balance or billing issue"
  671. case 403:
  672. statusCode = http.StatusBadGateway
  673. errType = "upstream_error"
  674. errMsg = "Upstream access forbidden, please contact administrator"
  675. case 429:
  676. statusCode = http.StatusTooManyRequests
  677. errType = "rate_limit_error"
  678. errMsg = "Upstream rate limit exceeded, please retry later"
  679. default:
  680. statusCode = http.StatusBadGateway
  681. errType = "upstream_error"
  682. errMsg = "Upstream request failed"
  683. }
  684. if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" {
  685. errMsg = upstreamMsg
  686. }
  687. c.JSON(statusCode, gin.H{
  688. "error": gin.H{
  689. "type": errType,
  690. "message": errMsg,
  691. },
  692. })
  693. if upstreamMsg == "" {
  694. return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
  695. }
  696. return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
  697. }
  698. // compatErrorWriter is the signature for format-specific error writers used by
  699. // the compat paths (Chat Completions and Anthropic Messages).
  700. type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string)
  701. // handleCompatErrorResponse is the shared non-failover error handler for the
  702. // Chat Completions and Anthropic Messages compat paths. It mirrors the logic of
  703. // handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit
  704. // tracking, secondary failover) but delegates the final error write to the
  705. // format-specific writer function.
  706. func (s *OpenAIGatewayService) handleCompatErrorResponse(
  707. resp *http.Response,
  708. c *gin.Context,
  709. account *Account,
  710. writeError compatErrorWriter,
  711. requestedModel ...string,
  712. ) (*OpenAIForwardResult, error) {
  713. body := s.readUpstreamErrorBody(resp)
  714. body = s.redactAgentIdentitySensitiveBody(context.Background(), account, body)
  715. // cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误,
  716. // 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络
  717. // 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方
  718. // handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
  719. if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
  720. MarkOpsCyberPolicy(c, CyberPolicyMark{
  721. Code: code,
  722. Message: cyberMsg,
  723. Body: truncateString(string(body), 4096),
  724. UpstreamStatus: resp.StatusCode,
  725. })
  726. setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
  727. clientMsg := cyberMsg
  728. if clientMsg == "" {
  729. clientMsg = "Request blocked by upstream cyber-security policy"
  730. }
  731. writeError(c, resp.StatusCode, "invalid_request_error", clientMsg)
  732. if cyberMsg == "" {
  733. return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
  734. }
  735. return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
  736. }
  737. if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(resp.StatusCode, body) {
  738. clientMsg := grokContentPolicyClientMessage(body)
  739. setOpsUpstreamError(c, resp.StatusCode, clientMsg, truncateString(string(body), 2048))
  740. MarkResponseCommitted(c)
  741. writeError(c, http.StatusForbidden, "invalid_request_error", clientMsg)
  742. return nil, fmt.Errorf("grok content policy rejection: %s", clientMsg)
  743. }
  744. upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
  745. if upstreamMsg == "" {
  746. upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode)
  747. }
  748. upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
  749. upstreamDetail := ""
  750. if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  751. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  752. if maxBytes <= 0 {
  753. maxBytes = 2048
  754. }
  755. upstreamDetail = truncateString(string(body), maxBytes)
  756. }
  757. setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
  758. // Apply error passthrough rules
  759. if status, errType, errMsg, matched := applyErrorPassthroughRule(
  760. c, account.Platform, resp.StatusCode, body,
  761. http.StatusBadGateway, "api_error", "Upstream request failed",
  762. ); matched {
  763. MarkResponseCommitted(c)
  764. writeError(c, status, errType, errMsg)
  765. if upstreamMsg == "" {
  766. upstreamMsg = errMsg
  767. }
  768. if upstreamMsg == "" {
  769. return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
  770. }
  771. return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
  772. }
  773. // Check custom error codes — if the account does not handle this status,
  774. // return a generic error without exposing upstream details.
  775. if !account.ShouldHandleErrorCode(resp.StatusCode) {
  776. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  777. ProxyID: opsUpstreamProxyID(account),
  778. ProxyName: opsUpstreamProxyName(account),
  779. Platform: account.Platform,
  780. AccountID: account.ID,
  781. AccountName: account.Name,
  782. UpstreamStatusCode: resp.StatusCode,
  783. UpstreamRequestID: resp.Header.Get("x-request-id"),
  784. Kind: "http_error",
  785. Message: upstreamMsg,
  786. Detail: upstreamDetail,
  787. })
  788. MarkResponseCommitted(c)
  789. writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error")
  790. if upstreamMsg == "" {
  791. return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
  792. }
  793. return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
  794. }
  795. // Track rate limits and decide whether to trigger secondary failover.
  796. var modelForCooldown string
  797. if len(requestedModel) > 0 {
  798. modelForCooldown = requestedModel[0]
  799. }
  800. shouldDisable := s.handleOpenAIAccountUpstreamError(
  801. c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown,
  802. )
  803. kind := "http_error"
  804. if shouldDisable {
  805. kind = "failover"
  806. }
  807. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  808. ProxyID: opsUpstreamProxyID(account),
  809. ProxyName: opsUpstreamProxyName(account),
  810. Platform: account.Platform,
  811. AccountID: account.ID,
  812. AccountName: account.Name,
  813. UpstreamStatusCode: resp.StatusCode,
  814. UpstreamRequestID: resp.Header.Get("x-request-id"),
  815. Kind: kind,
  816. Message: upstreamMsg,
  817. Detail: upstreamDetail,
  818. })
  819. if shouldDisable {
  820. return nil, &UpstreamFailoverError{
  821. StatusCode: resp.StatusCode,
  822. ResponseBody: body,
  823. RetryableOnSameAccount: false,
  824. }
  825. }
  826. MarkResponseCommitted(c)
  827. // Map status code to error type and write response
  828. errType := "api_error"
  829. switch {
  830. case resp.StatusCode == 400:
  831. errType = "invalid_request_error"
  832. case resp.StatusCode == 404:
  833. errType = "not_found_error"
  834. case resp.StatusCode == 429:
  835. errType = "rate_limit_error"
  836. case resp.StatusCode >= 500:
  837. errType = "api_error"
  838. }
  839. writeError(c, resp.StatusCode, errType, upstreamMsg)
  840. return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
  841. }