package service import ( "errors" "net/http" "strings" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" ) type openAICompactFallbackSignal struct { payload []byte message string } func (e *openAICompactFallbackSignal) Error() string { if e == nil || strings.TrimSpace(e.message) == "" { return "upstream compact request failed" } return e.message } func asOpenAICompactFallbackSignal(err error) (*openAICompactFallbackSignal, bool) { var signal *openAICompactFallbackSignal return signal, errors.As(err, &signal) && signal != nil } func isExplicitOpenAICompactContext(c *gin.Context) bool { return isOpenAIResponsesCompactPath(c) || isOpenAINativeCompactionV2(c) } func newOpenAICompactFallbackSignal(c *gin.Context, payload []byte, message string) error { if !isExplicitOpenAICompactContext(c) || !isOpenAICompactModelFailure(http.StatusBadRequest, message, payload) { return nil } return &openAICompactFallbackSignal{ payload: append([]byte(nil), payload...), message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)), } } func isExplicitOpenAICompactRequest(c *gin.Context, body []byte) bool { return isOpenAIResponsesCompactPath(c) || HasCompactionTriggerInInput(body) } // resolveOpenAICompactFallbackModel prefers the account's compact-only rule // for the client-visible model. The process-wide fallback is used only when // that account has no matching compact rule. func (s *OpenAIGatewayService) resolveOpenAICompactFallbackModel(account *Account, requestedModel string) string { requestedModel = strings.TrimSpace(requestedModel) if account != nil { if mapped, matched := account.ResolveCompactMappedModel(requestedModel); matched { if mapped = strings.TrimSpace(mapped); mapped != "" { return mapped } } } if s == nil || s.cfg == nil { return "" } fallback := strings.TrimSpace(s.cfg.Gateway.OpenAICompactModel) if fallback == "" { return "" } return strings.TrimSpace(resolveOpenAIAccountUpstreamModelForRequest(account, fallback, false)) } func isOpenAICompactModelFailure(statusCode int, upstreamMsg string, upstreamBody []byte) bool { if isOpenAIContextWindowError(upstreamMsg, upstreamBody) { return true } if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound { return false } values := []string{ extractUpstreamErrorCode(upstreamBody), upstreamMsg, gjson.GetBytes(upstreamBody, "error.type").String(), gjson.GetBytes(upstreamBody, "response.error.code").String(), gjson.GetBytes(upstreamBody, "response.error.type").String(), } for _, value := range values { value = strings.ToLower(strings.TrimSpace(value)) switch value { case "model_not_found", "model_not_available", "unsupported_model", "invalid_model": return true } if strings.Contains(value, "model") && (strings.Contains(value, "not found") || strings.Contains(value, "does not exist") || strings.Contains(value, "unavailable") || strings.Contains(value, "unsupported") || strings.Contains(value, "not supported")) { return true } } // Some compact providers return only a failed response shell. It is safe to // retry that shape for an explicit compact request, but a populated error is // left untouched so business and policy failures keep their original wire. if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "response.status").String()), "failed") || strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "status").String()), "failed") { for _, path := range []string{ "error.message", "error.code", "error.type", "response.error.message", "response.error.code", "response.error.type", } { if strings.TrimSpace(gjson.GetBytes(upstreamBody, path).String()) != "" { return false } } return strings.TrimSpace(upstreamMsg) == "" } return false } // prepareOpenAICompactFallbackRetry returns a body for one safe, same-account // retry. Callers invoke it only before any downstream response has been // written; it changes the model and deliberately leaves path, trigger, and // native-v2 context state untouched. func (s *OpenAIGatewayService) prepareOpenAICompactFallbackRetry( c *gin.Context, account *Account, requestedModel string, currentBody []byte, statusCode int, upstreamMsg string, upstreamBody []byte, alreadyRetried bool, ) ([]byte, string, bool) { if alreadyRetried || !isExplicitOpenAICompactRequest(c, currentBody) || !isOpenAICompactModelFailure(statusCode, upstreamMsg, upstreamBody) { return currentBody, "", false } fallbackModel := s.resolveOpenAICompactFallbackModel(account, requestedModel) currentModel := strings.TrimSpace(gjson.GetBytes(currentBody, "model").String()) if fallbackModel == "" || strings.EqualFold(fallbackModel, currentModel) { return currentBody, "", false } retryBody := ReplaceModelInBody(currentBody, fallbackModel) if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(retryBody, "model").String()), currentModel) { return currentBody, "", false } return retryBody, fallbackModel, true }