Files
sub2api/backend/internal/service/openai_compact_fallback.go
T

298 lines
9.5 KiB
Go

package service
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"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 isExplicitOpenAIModelAvailabilityMessage(value) {
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
}
func isExplicitOpenAIModelAvailabilityMessage(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return false
}
for _, phrase := range []string{
"model not found",
"model does not exist",
"model is unavailable",
"model is not available",
"model is unsupported",
"model is not supported",
"unsupported model",
} {
if strings.Contains(value, phrase) {
return true
}
}
// OpenAI commonly identifies the missing model between the word "model"
// and the terminal availability phrase, for example: "The model `x` does
// not exist". Requiring the message to start with the model subject avoids
// treating unrelated feature errors such as "model output is not supported"
// as a signal to change models.
if strings.HasPrefix(value, "the model ") || strings.HasPrefix(value, "model ") {
return strings.Contains(value, " does not exist") ||
strings.Contains(value, " was not found") ||
strings.Contains(value, " is unavailable") ||
strings.Contains(value, " is not available")
}
return false
}
func openAICompactFallbackErrorResponse(resp *http.Response, signal *openAICompactFallbackSignal) (*http.Response, []byte) {
headers := make(http.Header)
if resp != nil {
headers = resp.Header.Clone()
}
if headers.Get("Content-Type") == "" {
headers.Set("Content-Type", "application/json")
}
payload := normalizeOpenAICompactFallbackHTTPErrorPayload(signal)
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: headers,
Body: io.NopCloser(bytes.NewReader(payload)),
}, payload
}
func normalizeOpenAICompactFallbackHTTPErrorPayload(signal *openAICompactFallbackSignal) []byte {
if signal == nil {
return nil
}
payload := append([]byte(nil), signal.payload...)
var terminal struct {
Error json.RawMessage `json:"error"`
Response struct {
Error json.RawMessage `json:"error"`
} `json:"response"`
}
if json.Unmarshal(payload, &terminal) != nil || len(bytes.TrimSpace(terminal.Response.Error)) == 0 ||
bytes.Equal(bytes.TrimSpace(terminal.Response.Error), []byte("null")) {
return payload
}
// Standard HTTP error handlers consume error.message/type/code. A streamed
// response.failed terminal nests the same object under response.error, so
// normalize only that envelope at the stream-to-HTTP boundary.
normalized, err := json.Marshal(struct {
Error json.RawMessage `json:"error"`
}{Error: terminal.Response.Error})
if err != nil {
return payload
}
return normalized
}
func (s *OpenAIGatewayService) appendOpenAICompactFallbackRetryOps(
c *gin.Context,
account *Account,
resp *http.Response,
payload []byte,
message string,
passthrough bool,
) {
if account == nil {
return
}
statusCode := http.StatusBadRequest
requestID := ""
if resp != nil {
statusCode = resp.StatusCode
requestID = resp.Header.Get("x-request-id")
}
detail := ""
if s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
if maxBytes <= 0 {
maxBytes = 2048
}
detail = truncateString(string(payload), maxBytes)
}
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
UpstreamStatusCode: statusCode,
UpstreamRequestID: requestID,
Passthrough: passthrough,
Kind: "retry",
Reason: "compact_model_fallback",
Message: sanitizeUpstreamErrorMessage(strings.TrimSpace(message)),
Detail: detail,
UpstreamResponseBody: detail,
})
}
// 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
}
func (s *OpenAIGatewayService) applyOpenAIPassthroughCompactFallbackFromSignal(
c *gin.Context,
account *Account,
requestedModel string,
body []byte,
err error,
alreadyRetried bool,
resp *http.Response,
) ([]byte, string, bool) {
signal, ok := asOpenAICompactFallbackSignal(err)
if !ok {
return body, "", false
}
retryBody, fallbackModel, retry := s.prepareOpenAICompactFallbackRetry(
c, account, requestedModel, body, http.StatusBadRequest, signal.message, signal.payload, alreadyRetried,
)
if !retry {
return body, "", false
}
s.appendOpenAICompactFallbackRetryOps(c, account, resp, signal.payload, signal.message, true)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
fromModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
accountName := ""
if account != nil {
accountName = account.Name
}
SetOpsUpstreamModel(c, fallbackModel)
logger.LegacyPrintf(
"service.openai_gateway",
"[OpenAI passthrough] Retrying explicit compact request once with fallback model (account: %s, from: %s, to: %s, upstream_code: %s)",
accountName, fromModel, fallbackModel, extractUpstreamErrorCode(signal.payload),
)
return retryBody, fallbackModel, true
}