| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- "go.uber.org/zap"
- )
- func logOpenAIInstructionsRequiredDebug(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- upstreamStatusCode int,
- upstreamMsg string,
- requestBody []byte,
- upstreamBody []byte,
- ) {
- msg := strings.TrimSpace(upstreamMsg)
- if !isOpenAIInstructionsRequiredError(upstreamStatusCode, msg, upstreamBody) {
- return
- }
- if ctx == nil {
- ctx = context.Background()
- }
- accountID := int64(0)
- accountName := ""
- if account != nil {
- accountID = account.ID
- accountName = strings.TrimSpace(account.Name)
- }
- userAgent := ""
- originator := ""
- if c != nil {
- userAgent = strings.TrimSpace(c.GetHeader("User-Agent"))
- originator = strings.TrimSpace(c.GetHeader("originator"))
- }
- fields := []zap.Field{
- zap.String("component", "service.openai_gateway"),
- zap.Int64("account_id", accountID),
- zap.String("account_name", accountName),
- zap.Int("upstream_status_code", upstreamStatusCode),
- zap.String("upstream_error_message", msg),
- zap.String("request_user_agent", userAgent),
- zap.Bool("codex_official_client_match", openai.IsCodexOfficialClientByHeaders(userAgent, originator)),
- }
- fields = appendCodexCLIOnlyRejectedRequestFields(fields, c, requestBody)
- logger.FromContext(ctx).With(fields...).Warn("OpenAI 上游返回 Instructions are required,已记录请求详情用于排查")
- }
- func isOpenAIInstructionsRequiredError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
- if upstreamStatusCode != http.StatusBadRequest {
- return false
- }
- hasInstructionRequired := func(text string) bool {
- lower := strings.ToLower(strings.TrimSpace(text))
- if lower == "" {
- return false
- }
- if strings.Contains(lower, "instructions are required") {
- return true
- }
- if strings.Contains(lower, "required parameter: 'instructions'") {
- return true
- }
- if strings.Contains(lower, "required parameter: instructions") {
- return true
- }
- if strings.Contains(lower, "missing required parameter") && strings.Contains(lower, "instructions") {
- return true
- }
- return strings.Contains(lower, "instruction") && strings.Contains(lower, "required")
- }
- if hasInstructionRequired(upstreamMsg) {
- return true
- }
- if len(upstreamBody) == 0 {
- return false
- }
- errMsg := gjson.GetBytes(upstreamBody, "error.message").String()
- errMsgLower := strings.ToLower(strings.TrimSpace(errMsg))
- errCode := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.code").String()))
- errParam := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.param").String()))
- errType := strings.ToLower(strings.TrimSpace(gjson.GetBytes(upstreamBody, "error.type").String()))
- if errParam == "instructions" {
- return true
- }
- if hasInstructionRequired(errMsg) {
- return true
- }
- if strings.Contains(errCode, "missing_required_parameter") && strings.Contains(errMsgLower, "instructions") {
- return true
- }
- if strings.Contains(errType, "invalid_request") && strings.Contains(errMsgLower, "instructions") && strings.Contains(errMsgLower, "required") {
- return true
- }
- return false
- }
- func isOpenAITransientProcessingError(upstreamStatusCode int, upstreamMsg string, upstreamBody []byte) bool {
- if upstreamStatusCode < http.StatusBadRequest {
- return false
- }
- hasOpenAIServerOverloadedCode := func(payload []byte) bool {
- code := strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "error.code").String()))
- if code == "" {
- code = strings.ToLower(strings.TrimSpace(gjson.GetBytes(payload, "response.error.code").String()))
- }
- return code == "server_is_overloaded" || code == "slow_down"
- }
- if len(upstreamBody) > 0 && hasOpenAIServerOverloadedCode(upstreamBody) {
- return true
- }
- if isOpenAICapacityShedMessage(upstreamMsg) ||
- isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "error.message").String()) ||
- isOpenAICapacityShedMessage(gjson.GetBytes(upstreamBody, "response.error.message").String()) ||
- (!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody))) {
- return true
- }
- if upstreamStatusCode != http.StatusBadRequest && upstreamStatusCode != http.StatusServiceUnavailable {
- return false
- }
- if upstreamStatusCode != http.StatusBadRequest {
- return false
- }
- match := func(text string) bool {
- lower := strings.ToLower(strings.TrimSpace(text))
- if lower == "" {
- return false
- }
- if strings.Contains(lower, "an error occurred while processing your request") {
- return true
- }
- if strings.Contains(lower, "selected model is at capacity") {
- return true
- }
- return strings.Contains(lower, "you can retry your request") &&
- strings.Contains(lower, "help.openai.com") &&
- strings.Contains(lower, "request id")
- }
- if match(upstreamMsg) {
- return true
- }
- if len(upstreamBody) == 0 {
- return false
- }
- if match(gjson.GetBytes(upstreamBody, "error.message").String()) {
- return true
- }
- if match(gjson.GetBytes(upstreamBody, "response.error.message").String()) ||
- match(gjson.GetBytes(upstreamBody, "message").String()) {
- return true
- }
- // A valid JSON error may echo arbitrary request content. Only its explicit
- // error fields are authoritative; scan the whole body only for non-JSON
- // providers that return a plain-text error response.
- return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody))
- }
- func isOpenAICapacityShedMessage(text string) bool {
- lower := strings.ToLower(strings.TrimSpace(text))
- return strings.Contains(lower, "server is overloaded") ||
- strings.Contains(lower, "servers are overloaded") ||
- strings.Contains(lower, "servers are currently overloaded")
- }
- func isOpenAIRequestScopedCapacityShed(upstreamMsg string, upstreamBody []byte) bool {
- return isOpenAIUpstreamCapacityShedEvent(upstreamBody) ||
- isOpenAICapacityShedMessage(upstreamMsg) ||
- (!gjson.ValidBytes(upstreamBody) && isOpenAICapacityShedMessage(string(upstreamBody)))
- }
- func isOpenAIContextWindowError(upstreamMsg string, upstreamBody []byte) bool {
- match := func(text string) bool {
- lower := strings.ToLower(strings.TrimSpace(text))
- if lower == "" {
- return false
- }
- if strings.Contains(lower, "context_too_large") || strings.Contains(lower, "context_length_exceeded") {
- return true
- }
- if strings.Contains(lower, "maximum context length") || strings.Contains(lower, "max context length") {
- return true
- }
- hasExceeded := strings.Contains(lower, "exceed") || strings.Contains(lower, "too large") || strings.Contains(lower, "too long")
- if strings.Contains(lower, "context window") && hasExceeded {
- return true
- }
- if strings.Contains(lower, "context length") && hasExceeded {
- return true
- }
- return strings.Contains(lower, "token limit") &&
- strings.Contains(lower, "context") &&
- hasExceeded
- }
- if match(upstreamMsg) {
- return true
- }
- if len(upstreamBody) == 0 {
- return false
- }
- for _, path := range []string{
- "error.message",
- "response.error.message",
- "message",
- "error.code",
- "response.error.code",
- "code",
- } {
- if match(gjson.GetBytes(upstreamBody, path).String()) {
- return true
- }
- }
- // Do not let echoed request content in a structured JSON error change the
- // retry/client-status classification. Plain-text upstream errors remain
- // supported by scanning the whole body only when it is not valid JSON.
- return !gjson.ValidBytes(upstreamBody) && match(string(upstreamBody))
- }
- func (s *OpenAIGatewayService) shouldFailoverUpstreamError(statusCode int) bool {
- switch statusCode {
- case 401, 402, 403, 405, 429, 529:
- return true
- default:
- return statusCode >= 500
- }
- }
- func (s *OpenAIGatewayService) shouldFailoverOpenAIUpstreamResponse(account *Account, statusCode int, upstreamMsg string, upstreamBody []byte) bool {
- // cyber_policy is request-scoped even when an intermediary wraps the
- // provider response in a retryable 5xx status. Never punish or rotate the
- // selected credential for it.
- if hit, _, _ := detectOpenAICyberPolicy(upstreamBody); hit {
- return false
- }
- if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
- return false
- }
- if isOpenAIHTTPUpstreamAccessStateError(statusCode, upstreamMsg, upstreamBody) {
- return true
- }
- if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, upstreamBody) {
- return true
- }
- // A missing model is account/provider availability, not a malformed client
- // request. Keep this unconditional exception inside the OpenAI-compatible
- // gateway and require an eligible account so Anthropic/Gemini paths retain
- // their existing opt-in 400 behavior.
- // A bare forwarding service has no account-selection owner to consume a
- // failover sentinel. In that mode (used by direct/single-account callers),
- // preserve the deterministic upstream 400 instead of returning an unwritten
- // retry signal. Managed gateway instances always have an account repository;
- // their handler can exclude this account and actually select another one.
- if s != nil && s.accountRepo != nil && account != nil && account.IsOpenAICompatible() && statusCode == http.StatusBadRequest &&
- isOpenAICompatibleModelNotFound400(upstreamBody) {
- return true
- }
- if s.shouldFailoverUpstreamError(statusCode) {
- return true
- }
- return isOpenAITransientProcessingError(statusCode, upstreamMsg, upstreamBody)
- }
- func isOpenAICompatibleModelNotFound400(respBody []byte) bool {
- code := strings.TrimSpace(extractUpstreamErrorCode(respBody))
- if code != "" {
- return strings.EqualFold(code, "model_not_found")
- }
- msg := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
- if msg == "" && !gjson.ValidBytes(respBody) {
- msg = strings.ToLower(strings.TrimSpace(string(respBody)))
- }
- return strings.Contains(msg, "unknown provider for model") ||
- strings.Contains(msg, "model not found") ||
- strings.Contains(msg, "model is not supported")
- }
- // IsOpenAICompatibleModelNotFound400 reports whether an OpenAI-compatible 400
- // is an account-specific missing-model response eligible for failover.
- func IsOpenAICompatibleModelNotFound400(respBody []byte) bool {
- return isOpenAICompatibleModelNotFound400(respBody)
- }
- // OpenAIRequestBodyTooLargeClientMessage is the fixed downstream message used
- // after all account-specific request body limit failovers are exhausted.
- const OpenAIRequestBodyTooLargeClientMessage = "Request payload is too large"
- const openAIRequestBodyTooLargeReason = GatewayFailureReason("openai_request_body_too_large")
- func isOpenAIRequestBodyTooLargeError(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
- return statusCode == http.StatusRequestEntityTooLarge && !isOpenAIContextWindowError(upstreamMsg, upstreamBody)
- }
- func newOpenAIUpstreamFailoverError(
- statusCode int,
- responseHeaders http.Header,
- responseBody []byte,
- upstreamMsg string,
- retryableOnSameAccount bool,
- ) *UpstreamFailoverError {
- requestScopedCapacity := isOpenAIRequestScopedCapacityShed(upstreamMsg, responseBody)
- failoverErr := &UpstreamFailoverError{
- StatusCode: statusCode,
- ResponseBody: responseBody,
- ResponseHeaders: responseHeaders.Clone(),
- RetryableOnSameAccount: retryableOnSameAccount || requestScopedCapacity,
- RequestScopedTransient: requestScopedCapacity,
- }
- if isOpenAIRequestBodyTooLargeError(statusCode, upstreamMsg, responseBody) {
- failoverErr.RetryableOnSameAccount = false
- failoverErr.RequestScopedTransient = false
- failoverErr.Scope = GatewayFailureScopeAccount
- failoverErr.Reason = openAIRequestBodyTooLargeReason
- failoverErr.NextAccountAction = NextAccountRetry
- failoverErr.ClientStatusCode = http.StatusRequestEntityTooLarge
- failoverErr.ClientMessage = OpenAIRequestBodyTooLargeClientMessage
- }
- if isOpenAIHTTPUpstreamAccessStateError(statusCode, upstreamMsg, responseBody) {
- failoverErr.RetryableOnSameAccount = false
- failoverErr.RequestScopedTransient = false
- failoverErr.Stage = GatewayFailureStageAccountAuth
- failoverErr.Scope = GatewayFailureScopeAccount
- failoverErr.Reason = OpenAIUpstreamAccessStateReason
- failoverErr.NextAccountAction = NextAccountRetry
- failoverErr.ClientStatusCode = http.StatusBadGateway
- failoverErr.ClientMessage = openAIUpstreamAccessUnavailableClientMessage
- } else if requestScopedCapacity {
- // Preserve the provider's actionable overload message after gateway
- // retries are exhausted, but expose it as a retryable server_error.
- failoverErr.ClientStatusCode = http.StatusServiceUnavailable
- failoverErr.ClientMessage = openAICapacityShedClientMessage(upstreamMsg, responseBody)
- }
- return failoverErr
- }
- func (s *OpenAIGatewayService) newOpenAIAccountFailoverError(
- account *Account,
- statusCode int,
- responseHeaders http.Header,
- responseBody []byte,
- upstreamMsg string,
- shouldDisable bool,
- retryableOnSameAccount bool,
- ) *UpstreamFailoverError {
- return s.newOpenAIAccountFailoverErrorWithClassificationHeaders(account, statusCode, responseHeaders, responseHeaders, responseBody, upstreamMsg, shouldDisable, retryableOnSameAccount)
- }
- func (s *OpenAIGatewayService) newOpenAIAccountFailoverErrorWithClassificationHeaders(
- account *Account,
- statusCode int,
- responseHeaders http.Header,
- classificationHeaders http.Header,
- responseBody []byte,
- upstreamMsg string,
- shouldDisable bool,
- retryableOnSameAccount bool,
- ) *UpstreamFailoverError {
- oauth429Retry := s.shouldRetryOpenAIOAuth429OnSameAccountWithResponse(account, statusCode, shouldDisable, classificationHeaders, responseBody)
- failoverErr := newOpenAIUpstreamFailoverError(
- statusCode,
- responseHeaders,
- responseBody,
- upstreamMsg,
- retryableOnSameAccount || oauth429Retry,
- )
- if oauth429Retry {
- failoverErr.SameAccountRetryDeadline = s.openAIOAuth429RetryDeadline(account)
- failoverErr.SameAccountRetryDelay = openAIOAuth429SameAccountRetryDelay(responseHeaders, failoverErr.SameAccountRetryDeadline)
- failoverErr.SameAccountRetryMax = openAIOAuth429MaxSameAccountRetries
- }
- return failoverErr
- }
- const (
- openAIUpstreamAccessUnavailableClientMessage = "Upstream access is temporarily unavailable, please retry later"
- // OpenAIUpstreamAccessStateReason marks a provider credential whose
- // account, workspace, or organization is unavailable.
- OpenAIUpstreamAccessStateReason = GatewayFailureReason("openai_upstream_access_state")
- // OpenAIHTTPContinuationUnsupportedReason identifies accounts that cannot
- // preserve an official Responses HTTP continuation without dropping state.
- OpenAIHTTPContinuationUnsupportedReason = GatewayFailureReason("openai_http_continuation_unsupported")
- )
- // isOpenAIUpstreamAccessStateError recognizes provider-side credential state
- // failures only from explicit structured codes. Free-form messages may contain
- // echoed user input, including inside stream terminal error.message fields.
- func isOpenAIUpstreamAccessStateError(_ string, body []byte) bool {
- if len(body) == 0 || !gjson.ValidBytes(body) {
- return false
- }
- for _, path := range []string{"error.code", "response.error.code", "detail.code", "code"} {
- if isOpenAIUpstreamAccessStateCode(gjson.GetBytes(body, path).String()) {
- return true
- }
- }
- return false
- }
- func isOpenAIUpstreamAccessStateCode(value string) bool {
- value = strings.ToLower(strings.TrimSpace(value))
- if value == "deactivated_workspace" {
- return true
- }
- for _, subject := range []string{"workspace", "account", "organization", "org"} {
- for _, state := range []string{"deactivated", "disabled", "suspended"} {
- if value == subject+"_"+state || value == state+"_"+subject {
- return true
- }
- }
- }
- return false
- }
- // isOpenAIHTTPUpstreamAccessStateError is deliberately status-independent:
- // known provider codes are durable evidence, while 401/403 messages without
- // such a code must flow through the existing authentication/403 policies.
- func isOpenAIHTTPUpstreamAccessStateError(_ int, _ string, body []byte) bool {
- return isOpenAIUpstreamAccessStateError("", body)
- }
- func openAICapacityShedClientMessage(upstreamMsg string, body []byte) string {
- for _, candidate := range []string{
- upstreamMsg,
- gjson.GetBytes(body, "error.message").String(),
- gjson.GetBytes(body, "response.error.message").String(),
- gjson.GetBytes(body, "message").String(),
- } {
- candidate = sanitizeUpstreamErrorMessage(strings.TrimSpace(candidate))
- if candidate != "" && isOpenAICapacityShedMessage(candidate) {
- return candidate
- }
- }
- return "Upstream service is temporarily overloaded, please retry later"
- }
- // IsOpenAIRequestBodyTooLarge reports whether another account may accept the
- // same request even though the selected account rejected its serialized size.
- func (e *UpstreamFailoverError) IsOpenAIRequestBodyTooLarge() bool {
- return e != nil && e.Reason == openAIRequestBodyTooLargeReason
- }
- // IsOpenAICapacityShed reports whether typed client fields were derived from a
- // recognized provider overload rather than supplied by an unrelated failure.
- func (e *UpstreamFailoverError) IsOpenAICapacityShed() bool {
- return e != nil && e.RequestScopedTransient && isOpenAIRequestScopedCapacityShed("", e.ResponseBody)
- }
- func marshalOpenAIUpstreamJSON(v any) ([]byte, error) {
- var buf bytes.Buffer
- enc := json.NewEncoder(&buf)
- enc.SetEscapeHTML(false)
- if err := enc.Encode(v); err != nil {
- return nil, err
- }
- out := buf.Bytes()
- if len(out) > 0 && out[len(out)-1] == '\n' {
- out = out[:len(out)-1]
- }
- return out, nil
- }
- func openAIUpstreamErrorBodyReadLimitForConfig(cfg *config.Config) int64 {
- limit := openAIUpstreamErrorBodyReadLimit
- if cfg != nil && cfg.Gateway.LogUpstreamErrorBody && cfg.Gateway.LogUpstreamErrorBodyMaxBytes > int(limit) {
- limit = int64(cfg.Gateway.LogUpstreamErrorBodyMaxBytes)
- }
- return limit
- }
- func (s *OpenAIGatewayService) readUpstreamErrorBody(resp *http.Response) []byte {
- if resp == nil || resp.Body == nil {
- return nil
- }
- cfg := (*config.Config)(nil)
- if s != nil {
- cfg = s.cfg
- }
- body, _ := io.ReadAll(io.LimitReader(resp.Body, openAIUpstreamErrorBodyReadLimitForConfig(cfg)))
- return body
- }
- func (s *OpenAIGatewayService) handleFailoverSideEffects(ctx context.Context, resp *http.Response, account *Account, responseBody []byte, canonicalModel ...string) bool {
- if len(canonicalModel) > 0 {
- return s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody, canonicalModel[0])
- }
- return s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, responseBody)
- }
- func (s *OpenAIGatewayService) handleErrorResponse(
- ctx context.Context,
- resp *http.Response,
- c *gin.Context,
- account *Account,
- requestBody []byte,
- requestedModel ...string,
- ) (*OpenAIForwardResult, error) {
- body := s.readUpstreamErrorBody(resp)
- body = s.redactAgentIdentitySensitiveBody(ctx, account, body)
- // cyber_policy 硬阻断:透传上游原始错误体给客户端(不重包成通用 502),不冷却账号。
- // 当前请求恒透传(需求1);标记供 handler 事后写风控/邮件。400 cyber 不可 failover
- // (shouldFailoverUpstreamError(400)=false),故走到此处即可安全早返回。
- if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
- MarkOpsCyberPolicy(c, CyberPolicyMark{
- Code: code,
- Message: cyberMsg,
- Body: truncateString(string(body), 4096),
- UpstreamStatus: resp.StatusCode,
- })
- setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
- writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- contentType := resp.Header.Get("Content-Type")
- if contentType == "" {
- contentType = "application/json"
- }
- c.Data(resp.StatusCode, contentType, body)
- if cyberMsg == "" {
- return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
- }
- return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
- }
- if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(resp.StatusCode, body) {
- clientMsg := grokContentPolicyClientMessage(body)
- setOpsUpstreamError(c, resp.StatusCode, clientMsg, truncateString(string(body), 2048))
- writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- MarkResponseCommitted(c)
- c.JSON(http.StatusForbidden, gin.H{
- "error": gin.H{
- "type": "invalid_request_error",
- "message": clientMsg,
- },
- })
- return nil, fmt.Errorf("grok content policy rejection: %s", clientMsg)
- }
- upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
- upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
- upstreamDetail := ""
- if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
- maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
- if maxBytes <= 0 {
- maxBytes = 2048
- }
- upstreamDetail = truncateString(string(body), maxBytes)
- }
- setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
- logOpenAIInstructionsRequiredDebug(ctx, c, account, resp.StatusCode, upstreamMsg, requestBody, body)
- if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
- logger.LegacyPrintf("service.openai_gateway",
- "OpenAI upstream error %d (account=%d platform=%s type=%s): %s",
- resp.StatusCode,
- account.ID,
- account.Platform,
- account.Type,
- truncateForLog(body, s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes),
- )
- }
- if isOpenAIRequestBodyTooLargeError(resp.StatusCode, upstreamMsg, body) {
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: resp.StatusCode,
- UpstreamRequestID: resp.Header.Get("x-request-id"),
- Kind: "failover",
- Message: upstreamMsg,
- Detail: upstreamDetail,
- })
- s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, requestedModel...)
- return nil, newOpenAIUpstreamFailoverError(
- resp.StatusCode,
- resp.Header,
- body,
- upstreamMsg,
- false,
- )
- }
- if status, errType, errMsg, matched := applyErrorPassthroughRule(
- c,
- PlatformOpenAI,
- resp.StatusCode,
- body,
- http.StatusBadGateway,
- "upstream_error",
- "Upstream request failed",
- ); matched {
- MarkResponseCommitted(c)
- c.JSON(status, gin.H{
- "error": gin.H{
- "type": errType,
- "message": errMsg,
- },
- })
- if upstreamMsg == "" {
- upstreamMsg = errMsg
- }
- if upstreamMsg == "" {
- return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
- }
- return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
- }
- // Check custom error codes
- if !account.ShouldHandleErrorCode(resp.StatusCode) {
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: resp.StatusCode,
- UpstreamRequestID: resp.Header.Get("x-request-id"),
- Kind: "http_error",
- Message: upstreamMsg,
- Detail: upstreamDetail,
- })
- MarkResponseCommitted(c)
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": gin.H{
- "type": "upstream_error",
- "message": "Upstream gateway error",
- },
- })
- if upstreamMsg == "" {
- return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
- }
- return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
- }
- // Handle upstream error (mark account status)
- var reqModel string
- if len(requestedModel) > 0 {
- reqModel = strings.TrimSpace(requestedModel[0])
- }
- if reqModel == "" {
- reqModel, _, _ = extractOpenAIRequestMetaFromBody(requestBody)
- reqModel = canonicalOpenAIAccountSchedulingModel(account, reqModel)
- }
- shouldDisable := s.handleOpenAIAccountUpstreamError(ctx, account, resp.StatusCode, resp.Header, body, reqModel)
- kind := "http_error"
- if shouldDisable {
- kind = "failover"
- }
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: resp.StatusCode,
- UpstreamRequestID: resp.Header.Get("x-request-id"),
- Kind: kind,
- Message: upstreamMsg,
- Detail: upstreamDetail,
- })
- if shouldDisable {
- return nil, &UpstreamFailoverError{
- StatusCode: resp.StatusCode,
- ResponseBody: body,
- RetryableOnSameAccount: false,
- }
- }
- MarkResponseCommitted(c)
- // 上游 400 是确定性的请求错误:同一份请求体换账号、重试多少次都会失败。归一成
- // 502 upstream_error 会让下游网关把它当成可重试的上游故障反复重放(#5479 实测
- // 30 个失败请求被放大成 60 次上游调用),同时抹掉客户端定位问题所需的 code/param。
- //
- // 走到这里说明 shouldFailoverOpenAIUpstreamResponse 已判定该 400 不可 failover,
- // 即 server_is_overloaded / at capacity 这类可重试的 400 不会到达此处。
- //
- // 兄弟路径早已这么做:handleCompatErrorResponse(ChatCompletions / Anthropic)
- // 回真实状态码 + invalid_request_error + 真实 message;/v1/images 还额外透传
- // code/param。原生 Responses 是唯一漏掉的一条。
- if isOpenAIDeterministicClientError(resp.StatusCode) {
- writeOpenAIUpstreamClientError(c, resp.StatusCode, body, upstreamMsg)
- if upstreamMsg == "" {
- return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
- }
- return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
- }
- // Return appropriate error response
- var errType, errMsg string
- var statusCode int
- switch resp.StatusCode {
- case 401:
- statusCode = http.StatusBadGateway
- errType = "upstream_error"
- errMsg = "Upstream authentication failed, please contact administrator"
- case 402:
- statusCode = http.StatusBadGateway
- errType = "upstream_error"
- errMsg = "Upstream payment required: insufficient balance or billing issue"
- case 403:
- statusCode = http.StatusBadGateway
- errType = "upstream_error"
- errMsg = "Upstream access forbidden, please contact administrator"
- case 429:
- statusCode = http.StatusTooManyRequests
- errType = "rate_limit_error"
- errMsg = "Upstream rate limit exceeded, please retry later"
- default:
- statusCode = http.StatusBadGateway
- errType = "upstream_error"
- errMsg = "Upstream request failed"
- }
- if isOpenAIContextWindowError(upstreamMsg, body) && upstreamMsg != "" {
- errMsg = upstreamMsg
- }
- c.JSON(statusCode, gin.H{
- "error": gin.H{
- "type": errType,
- "message": errMsg,
- },
- })
- if upstreamMsg == "" {
- return nil, fmt.Errorf("upstream error: %d", resp.StatusCode)
- }
- return nil, fmt.Errorf("upstream error: %d message=%s", resp.StatusCode, upstreamMsg)
- }
- // compatErrorWriter is the signature for format-specific error writers used by
- // the compat paths (Chat Completions and Anthropic Messages).
- type compatErrorWriter func(c *gin.Context, statusCode int, errType, message string)
- // handleCompatErrorResponse is the shared non-failover error handler for the
- // Chat Completions and Anthropic Messages compat paths. It mirrors the logic of
- // handleErrorResponse (passthrough rules, ShouldHandleErrorCode, rate-limit
- // tracking, secondary failover) but delegates the final error write to the
- // format-specific writer function.
- func (s *OpenAIGatewayService) handleCompatErrorResponse(
- resp *http.Response,
- c *gin.Context,
- account *Account,
- writeError compatErrorWriter,
- requestedModel ...string,
- ) (*OpenAIForwardResult, error) {
- body := s.readUpstreamErrorBody(resp)
- body = s.redactAgentIdentitySensitiveBody(context.Background(), account, body)
- // cyber_policy:兼容路径(Chat Completions / Anthropic)以各自格式回写错误,
- // 不原样透传 responses 格式的 cyber body(否则对下游格式不合法)。cyber 是上游网络
- // 安全策略拦截,不冷却账号,故标记后直接以兼容格式回写错误并返回,跳过下方
- // handleOpenAIAccountUpstreamError(避免自定义 temp-unschedulable 规则误冷却)。
- if hit, code, cyberMsg := detectOpenAICyberPolicy(body); hit {
- MarkOpsCyberPolicy(c, CyberPolicyMark{
- Code: code,
- Message: cyberMsg,
- Body: truncateString(string(body), 4096),
- UpstreamStatus: resp.StatusCode,
- })
- setOpsUpstreamError(c, resp.StatusCode, cyberMsg, truncateString(string(body), 2048))
- clientMsg := cyberMsg
- if clientMsg == "" {
- clientMsg = "Request blocked by upstream cyber-security policy"
- }
- writeError(c, resp.StatusCode, "invalid_request_error", clientMsg)
- if cyberMsg == "" {
- return nil, fmt.Errorf("openai cyber_policy: %d", resp.StatusCode)
- }
- return nil, fmt.Errorf("openai cyber_policy: %s", cyberMsg)
- }
- if account != nil && account.Platform == PlatformGrok && isGrokContentPolicyRejection(resp.StatusCode, body) {
- clientMsg := grokContentPolicyClientMessage(body)
- setOpsUpstreamError(c, resp.StatusCode, clientMsg, truncateString(string(body), 2048))
- MarkResponseCommitted(c)
- writeError(c, http.StatusForbidden, "invalid_request_error", clientMsg)
- return nil, fmt.Errorf("grok content policy rejection: %s", clientMsg)
- }
- upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(body))
- if upstreamMsg == "" {
- upstreamMsg = fmt.Sprintf("Upstream error: %d", resp.StatusCode)
- }
- upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg)
- upstreamDetail := ""
- if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
- maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
- if maxBytes <= 0 {
- maxBytes = 2048
- }
- upstreamDetail = truncateString(string(body), maxBytes)
- }
- setOpsUpstreamError(c, resp.StatusCode, upstreamMsg, upstreamDetail)
- // Apply error passthrough rules
- if status, errType, errMsg, matched := applyErrorPassthroughRule(
- c, account.Platform, resp.StatusCode, body,
- http.StatusBadGateway, "api_error", "Upstream request failed",
- ); matched {
- MarkResponseCommitted(c)
- writeError(c, status, errType, errMsg)
- if upstreamMsg == "" {
- upstreamMsg = errMsg
- }
- if upstreamMsg == "" {
- return nil, fmt.Errorf("upstream error: %d (passthrough rule matched)", resp.StatusCode)
- }
- return nil, fmt.Errorf("upstream error: %d (passthrough rule matched) message=%s", resp.StatusCode, upstreamMsg)
- }
- // Check custom error codes — if the account does not handle this status,
- // return a generic error without exposing upstream details.
- if !account.ShouldHandleErrorCode(resp.StatusCode) {
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: resp.StatusCode,
- UpstreamRequestID: resp.Header.Get("x-request-id"),
- Kind: "http_error",
- Message: upstreamMsg,
- Detail: upstreamDetail,
- })
- MarkResponseCommitted(c)
- writeError(c, http.StatusInternalServerError, "api_error", "Upstream gateway error")
- if upstreamMsg == "" {
- return nil, fmt.Errorf("upstream error: %d (not in custom error codes)", resp.StatusCode)
- }
- return nil, fmt.Errorf("upstream error: %d (not in custom error codes) message=%s", resp.StatusCode, upstreamMsg)
- }
- // Track rate limits and decide whether to trigger secondary failover.
- var modelForCooldown string
- if len(requestedModel) > 0 {
- modelForCooldown = requestedModel[0]
- }
- shouldDisable := s.handleOpenAIAccountUpstreamError(
- c.Request.Context(), account, resp.StatusCode, resp.Header, body, modelForCooldown,
- )
- kind := "http_error"
- if shouldDisable {
- kind = "failover"
- }
- appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
- ProxyID: opsUpstreamProxyID(account),
- ProxyName: opsUpstreamProxyName(account),
- Platform: account.Platform,
- AccountID: account.ID,
- AccountName: account.Name,
- UpstreamStatusCode: resp.StatusCode,
- UpstreamRequestID: resp.Header.Get("x-request-id"),
- Kind: kind,
- Message: upstreamMsg,
- Detail: upstreamDetail,
- })
- if shouldDisable {
- return nil, &UpstreamFailoverError{
- StatusCode: resp.StatusCode,
- ResponseBody: body,
- RetryableOnSameAccount: false,
- }
- }
- MarkResponseCommitted(c)
- // Map status code to error type and write response
- errType := "api_error"
- switch {
- case resp.StatusCode == 400:
- errType = "invalid_request_error"
- case resp.StatusCode == 404:
- errType = "not_found_error"
- case resp.StatusCode == 429:
- errType = "rate_limit_error"
- case resp.StatusCode >= 500:
- errType = "api_error"
- }
- writeError(c, resp.StatusCode, errType, upstreamMsg)
- return nil, fmt.Errorf("upstream error: %d %s", resp.StatusCode, upstreamMsg)
- }
|