openai_compact_fallback.go 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. package service
  2. import (
  3. "bytes"
  4. "encoding/json"
  5. "errors"
  6. "io"
  7. "net/http"
  8. "strings"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  10. "github.com/gin-gonic/gin"
  11. "github.com/tidwall/gjson"
  12. )
  13. type openAICompactFallbackSignal struct {
  14. payload []byte
  15. message string
  16. }
  17. func (e *openAICompactFallbackSignal) Error() string {
  18. if e == nil || strings.TrimSpace(e.message) == "" {
  19. return "upstream compact request failed"
  20. }
  21. return e.message
  22. }
  23. func asOpenAICompactFallbackSignal(err error) (*openAICompactFallbackSignal, bool) {
  24. var signal *openAICompactFallbackSignal
  25. return signal, errors.As(err, &signal) && signal != nil
  26. }
  27. func isExplicitOpenAICompactContext(c *gin.Context) bool {
  28. return isOpenAIResponsesCompactPath(c) || isOpenAINativeCompactionV2(c)
  29. }
  30. func newOpenAICompactFallbackSignal(c *gin.Context, payload []byte, message string) error {
  31. if !isExplicitOpenAICompactContext(c) ||
  32. !isOpenAICompactModelFailure(http.StatusBadRequest, message, payload) {
  33. return nil
  34. }
  35. return &openAICompactFallbackSignal{
  36. payload: append([]byte(nil), payload...),
  37. message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)),
  38. }
  39. }
  40. func isExplicitOpenAICompactRequest(c *gin.Context, body []byte) bool {
  41. return isOpenAIResponsesCompactPath(c) || HasCompactionTriggerInInput(body)
  42. }
  43. // resolveOpenAICompactFallbackModel prefers the account's compact-only rule
  44. // for the client-visible model. The process-wide fallback is used only when
  45. // that account has no matching compact rule.
  46. func (s *OpenAIGatewayService) resolveOpenAICompactFallbackModel(account *Account, requestedModel string) string {
  47. requestedModel = strings.TrimSpace(requestedModel)
  48. if account != nil {
  49. if mapped, matched := account.ResolveCompactMappedModel(requestedModel); matched {
  50. if mapped = strings.TrimSpace(mapped); mapped != "" {
  51. return mapped
  52. }
  53. }
  54. }
  55. if s == nil || s.cfg == nil {
  56. return ""
  57. }
  58. fallback := strings.TrimSpace(s.cfg.Gateway.OpenAICompactModel)
  59. if fallback == "" {
  60. return ""
  61. }
  62. return strings.TrimSpace(resolveOpenAIAccountUpstreamModelForRequest(account, fallback, false))
  63. }
  64. func isOpenAICompactModelFailure(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
  65. if isOpenAIContextWindowError(upstreamMsg, upstreamBody) {
  66. return true
  67. }
  68. if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound {
  69. return false
  70. }
  71. values := []string{
  72. extractUpstreamErrorCode(upstreamBody),
  73. upstreamMsg,
  74. gjson.GetBytes(upstreamBody, "error.type").String(),
  75. gjson.GetBytes(upstreamBody, "response.error.code").String(),
  76. gjson.GetBytes(upstreamBody, "response.error.type").String(),
  77. }
  78. for _, value := range values {
  79. value = strings.ToLower(strings.TrimSpace(value))
  80. switch value {
  81. case "model_not_found", "model_not_available", "unsupported_model", "invalid_model":
  82. return true
  83. }
  84. if isExplicitOpenAIModelAvailabilityMessage(value) {
  85. return true
  86. }
  87. }
  88. // Some compact providers return only a failed response shell. It is safe to
  89. // retry that shape for an explicit compact request, but a populated error is
  90. // left untouched so business and policy failures keep their original wire.
  91. if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "response.status").String()), "failed") ||
  92. strings.EqualFold(strings.TrimSpace(gjson.GetBytes(upstreamBody, "status").String()), "failed") {
  93. for _, path := range []string{
  94. "error.message", "error.code", "error.type",
  95. "response.error.message", "response.error.code", "response.error.type",
  96. } {
  97. if strings.TrimSpace(gjson.GetBytes(upstreamBody, path).String()) != "" {
  98. return false
  99. }
  100. }
  101. return strings.TrimSpace(upstreamMsg) == ""
  102. }
  103. return false
  104. }
  105. func isExplicitOpenAIModelAvailabilityMessage(value string) bool {
  106. value = strings.ToLower(strings.TrimSpace(value))
  107. if value == "" {
  108. return false
  109. }
  110. for _, phrase := range []string{
  111. "model not found",
  112. "model does not exist",
  113. "model is unavailable",
  114. "model is not available",
  115. "model is unsupported",
  116. "model is not supported",
  117. "unsupported model",
  118. } {
  119. if strings.Contains(value, phrase) {
  120. return true
  121. }
  122. }
  123. // OpenAI commonly identifies the missing model between the word "model"
  124. // and the terminal availability phrase, for example: "The model `x` does
  125. // not exist". Requiring the message to start with the model subject avoids
  126. // treating unrelated feature errors such as "model output is not supported"
  127. // as a signal to change models.
  128. if strings.HasPrefix(value, "the model ") || strings.HasPrefix(value, "model ") {
  129. return strings.Contains(value, " does not exist") ||
  130. strings.Contains(value, " was not found") ||
  131. strings.Contains(value, " is unavailable") ||
  132. strings.Contains(value, " is not available")
  133. }
  134. return false
  135. }
  136. func openAICompactFallbackErrorResponse(resp *http.Response, signal *openAICompactFallbackSignal) (*http.Response, []byte) {
  137. headers := make(http.Header)
  138. if resp != nil {
  139. headers = resp.Header.Clone()
  140. }
  141. if headers.Get("Content-Type") == "" {
  142. headers.Set("Content-Type", "application/json")
  143. }
  144. payload := normalizeOpenAICompactFallbackHTTPErrorPayload(signal)
  145. return &http.Response{
  146. StatusCode: http.StatusBadRequest,
  147. Header: headers,
  148. Body: io.NopCloser(bytes.NewReader(payload)),
  149. }, payload
  150. }
  151. func normalizeOpenAICompactFallbackHTTPErrorPayload(signal *openAICompactFallbackSignal) []byte {
  152. if signal == nil {
  153. return nil
  154. }
  155. payload := append([]byte(nil), signal.payload...)
  156. var terminal struct {
  157. Error json.RawMessage `json:"error"`
  158. Response struct {
  159. Error json.RawMessage `json:"error"`
  160. } `json:"response"`
  161. }
  162. if json.Unmarshal(payload, &terminal) != nil || len(bytes.TrimSpace(terminal.Response.Error)) == 0 ||
  163. bytes.Equal(bytes.TrimSpace(terminal.Response.Error), []byte("null")) {
  164. return payload
  165. }
  166. // Standard HTTP error handlers consume error.message/type/code. A streamed
  167. // response.failed terminal nests the same object under response.error, so
  168. // normalize only that envelope at the stream-to-HTTP boundary.
  169. normalized, err := json.Marshal(struct {
  170. Error json.RawMessage `json:"error"`
  171. }{Error: terminal.Response.Error})
  172. if err != nil {
  173. return payload
  174. }
  175. return normalized
  176. }
  177. func (s *OpenAIGatewayService) appendOpenAICompactFallbackRetryOps(
  178. c *gin.Context,
  179. account *Account,
  180. resp *http.Response,
  181. payload []byte,
  182. message string,
  183. passthrough bool,
  184. ) {
  185. if account == nil {
  186. return
  187. }
  188. statusCode := http.StatusBadRequest
  189. requestID := ""
  190. if resp != nil {
  191. statusCode = resp.StatusCode
  192. requestID = resp.Header.Get("x-request-id")
  193. }
  194. detail := ""
  195. if s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
  196. maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
  197. if maxBytes <= 0 {
  198. maxBytes = 2048
  199. }
  200. detail = truncateString(string(payload), maxBytes)
  201. }
  202. appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
  203. ProxyID: opsUpstreamProxyID(account),
  204. ProxyName: opsUpstreamProxyName(account),
  205. Platform: account.Platform,
  206. AccountID: account.ID,
  207. AccountName: account.Name,
  208. UpstreamStatusCode: statusCode,
  209. UpstreamRequestID: requestID,
  210. Passthrough: passthrough,
  211. Kind: "retry",
  212. Reason: "compact_model_fallback",
  213. Message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)),
  214. Detail: detail,
  215. UpstreamResponseBody: detail,
  216. })
  217. }
  218. // prepareOpenAICompactFallbackRetry returns a body for one safe, same-account
  219. // retry. Callers invoke it only before any downstream response has been
  220. // written; it changes the model and deliberately leaves path, trigger, and
  221. // native-v2 context state untouched.
  222. func (s *OpenAIGatewayService) prepareOpenAICompactFallbackRetry(
  223. c *gin.Context,
  224. account *Account,
  225. requestedModel string,
  226. currentBody []byte,
  227. statusCode int,
  228. upstreamMsg string,
  229. upstreamBody []byte,
  230. alreadyRetried bool,
  231. ) ([]byte, string, bool) {
  232. if alreadyRetried || !isExplicitOpenAICompactRequest(c, currentBody) ||
  233. !isOpenAICompactModelFailure(statusCode, upstreamMsg, upstreamBody) {
  234. return currentBody, "", false
  235. }
  236. fallbackModel := s.resolveOpenAICompactFallbackModel(account, requestedModel)
  237. currentModel := strings.TrimSpace(gjson.GetBytes(currentBody, "model").String())
  238. if fallbackModel == "" || strings.EqualFold(fallbackModel, currentModel) {
  239. return currentBody, "", false
  240. }
  241. retryBody := ReplaceModelInBody(currentBody, fallbackModel)
  242. if strings.EqualFold(strings.TrimSpace(gjson.GetBytes(retryBody, "model").String()), currentModel) {
  243. return currentBody, "", false
  244. }
  245. return retryBody, fallbackModel, true
  246. }
  247. func (s *OpenAIGatewayService) applyOpenAIPassthroughCompactFallbackFromSignal(
  248. c *gin.Context,
  249. account *Account,
  250. requestedModel string,
  251. body []byte,
  252. err error,
  253. alreadyRetried bool,
  254. resp *http.Response,
  255. ) ([]byte, string, bool) {
  256. signal, ok := asOpenAICompactFallbackSignal(err)
  257. if !ok {
  258. return body, "", false
  259. }
  260. retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
  261. c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, alreadyRetried,
  262. )
  263. if !retry {
  264. return body, "", false
  265. }
  266. s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, true)
  267. if resp != nil && resp.Body != nil {
  268. _ = resp.Body.Close()
  269. }
  270. fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
  271. accountName := ""
  272. if account != nil {
  273. accountName = account.Name
  274. }
  275. SetOpsUpstreamModel(c, fallbackModel)
  276. logger.LegacyPrintf(
  277. "service.openai_gateway",
  278. "[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
  279. accountName, fromModel, fallbackModel, extractUpstreamErrorCode(signal.payload),
  280. )
  281. return retryBody, fallbackModel, true
  282. }