Files
sub2api/backend/internal/service/openai_messages_continuation.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

332 lines
9.3 KiB
Go

package service
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
type openAICompatSessionResponseBinding struct {
ResponseID string
TurnState string
ContinuationDisabled bool
ExpiresAt time.Time
}
func openAICompatContinuationEnabled(account *Account, model string) bool {
if account == nil || account.Type != AccountTypeAPIKey {
return false
}
return shouldAutoInjectPromptCacheKeyForCompat(model)
}
func trimAnthropicCompatResponsesInputToLatestTurn(req *apicompat.ResponsesRequest) {
if req == nil || len(req.Input) == 0 {
return
}
var items []apicompat.ResponsesInputItem
if err := json.Unmarshal(req.Input, &items); err != nil || len(items) == 0 {
return
}
start := latestAnthropicCompatResponsesInputTurnStart(items)
trimmed := append([]apicompat.ResponsesInputItem(nil), items[start:]...)
if len(trimmed) == len(items) {
return
}
if input, err := json.Marshal(trimmed); err == nil {
req.Input = input
}
}
func latestAnthropicCompatResponsesInputTurnStart(items []apicompat.ResponsesInputItem) int {
if len(items) == 0 {
return 0
}
start := len(items) - 1
last := items[start]
switch {
case last.Type == "function_call_output":
for start > 0 && items[start-1].Type == "function_call_output" {
start--
}
case last.Type == "message" && last.Role == "user":
for start > 0 && items[start-1].Type == "function_call_output" {
start--
}
default:
return start
}
return expandAnthropicCompatResponsesInputToolCallStart(items, start)
}
func expandAnthropicCompatResponsesInputToolCallStart(items []apicompat.ResponsesInputItem, start int) int {
if start < 0 || start >= len(items) {
return start
}
needed := make(map[string]struct{})
for i := start; i < len(items); i++ {
if items[i].Type != "function_call_output" {
continue
}
callID := strings.TrimSpace(items[i].CallID)
if callID != "" {
needed[callID] = struct{}{}
}
}
if len(needed) == 0 {
return start
}
expandedStart := start
for i := start - 1; i >= 0 && len(needed) > 0; i-- {
if items[i].Type != "function_call" {
continue
}
callID := strings.TrimSpace(items[i].CallID)
if _, ok := needed[callID]; !ok {
continue
}
delete(needed, callID)
expandedStart = i
}
return expandedStart
}
func isOpenAICompatPreviousResponseNotFound(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound {
return false
}
check := func(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
return strings.Contains(lower, "previous_response_not_found") ||
(strings.Contains(lower, "previous response") && strings.Contains(lower, "not found")) ||
(strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "previous_response_id"))
}
if check(upstreamMsg) || check(string(upstreamBody)) {
return true
}
return check(gjson.GetBytes(upstreamBody, "error.code").String()) ||
check(gjson.GetBytes(upstreamBody, "error.message").String())
}
func isOpenAICompatPreviousResponseUnsupported(statusCode int, upstreamMsg string, upstreamBody []byte) bool {
if statusCode != http.StatusBadRequest {
return false
}
check := func(s string) bool {
lower := strings.ToLower(strings.TrimSpace(s))
if !strings.Contains(lower, "previous_response_id") {
return false
}
return strings.Contains(lower, "unsupported parameter") ||
strings.Contains(lower, "only supported on responses websocket") ||
strings.Contains(lower, "not supported")
}
if check(upstreamMsg) || check(string(upstreamBody)) {
return true
}
return check(gjson.GetBytes(upstreamBody, "error.code").String()) ||
check(gjson.GetBytes(upstreamBody, "error.message").String())
}
func openAICompatSessionResponseKey(c *gin.Context, account *Account, promptCacheKey string) string {
key := strings.TrimSpace(promptCacheKey)
if account == nil || key == "" {
return ""
}
apiKeyID := int64(0)
if c != nil {
apiKeyID = getAPIKeyIDFromContext(c)
}
return strings.Join([]string{
strconv.FormatInt(account.ID, 10),
strconv.FormatInt(apiKeyID, 10),
key,
}, "\x00")
}
func (s *OpenAIGatewayService) getOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) string {
if s == nil {
return ""
}
key := openAICompatSessionResponseKey(c, account, promptCacheKey)
if key == "" {
return ""
}
raw, ok := s.openaiCompatSessionResponses.Load(key)
if !ok {
return ""
}
binding, ok := raw.(openAICompatSessionResponseBinding)
if !ok {
s.openaiCompatSessionResponses.Delete(key)
return ""
}
if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) {
s.openaiCompatSessionResponses.Delete(key)
return ""
}
if binding.ContinuationDisabled {
return ""
}
if strings.TrimSpace(binding.ResponseID) == "" {
s.openaiCompatSessionResponses.Delete(key)
return ""
}
return strings.TrimSpace(binding.ResponseID)
}
func (s *OpenAIGatewayService) bindOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey, responseID string) {
if s == nil {
return
}
key := openAICompatSessionResponseKey(c, account, promptCacheKey)
id := strings.TrimSpace(responseID)
if key == "" || id == "" {
return
}
binding := openAICompatSessionResponseBinding{
ResponseID: id,
ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()),
}
if raw, ok := s.openaiCompatSessionResponses.Load(key); ok {
if existing, ok := raw.(openAICompatSessionResponseBinding); ok {
if existing.ContinuationDisabled {
existing.ResponseID = ""
existing.ExpiresAt = time.Now().Add(s.openAIWSResponseStickyTTL())
s.openaiCompatSessionResponses.Store(key, existing)
return
}
binding.TurnState = existing.TurnState
}
}
s.openaiCompatSessionResponses.Store(key, binding)
}
func (s *OpenAIGatewayService) deleteOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) {
if s == nil {
return
}
key := openAICompatSessionResponseKey(c, account, promptCacheKey)
if key == "" {
return
}
raw, ok := s.openaiCompatSessionResponses.Load(key)
if !ok {
return
}
binding, ok := raw.(openAICompatSessionResponseBinding)
if !ok {
s.openaiCompatSessionResponses.Delete(key)
return
}
binding.ResponseID = ""
if strings.TrimSpace(binding.TurnState) == "" && !binding.ContinuationDisabled {
s.openaiCompatSessionResponses.Delete(key)
return
}
binding.ExpiresAt = time.Now().Add(s.openAIWSResponseStickyTTL())
s.openaiCompatSessionResponses.Store(key, binding)
}
func (s *OpenAIGatewayService) disableOpenAICompatSessionContinuation(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) {
if s == nil {
return
}
key := openAICompatSessionResponseKey(c, account, promptCacheKey)
if key == "" {
return
}
binding := openAICompatSessionResponseBinding{
ContinuationDisabled: true,
ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()),
}
if raw, ok := s.openaiCompatSessionResponses.Load(key); ok {
if existing, ok := raw.(openAICompatSessionResponseBinding); ok {
binding.TurnState = existing.TurnState
}
}
s.openaiCompatSessionResponses.Store(key, binding)
}
func (s *OpenAIGatewayService) isOpenAICompatSessionContinuationDisabled(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) bool {
if s == nil {
return false
}
key := openAICompatSessionResponseKey(c, account, promptCacheKey)
if key == "" {
return false
}
raw, ok := s.openaiCompatSessionResponses.Load(key)
if !ok {
return false
}
binding, ok := raw.(openAICompatSessionResponseBinding)
if !ok {
s.openaiCompatSessionResponses.Delete(key)
return false
}
if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) {
s.openaiCompatSessionResponses.Delete(key)
return false
}
return binding.ContinuationDisabled
}
func (s *OpenAIGatewayService) getOpenAICompatSessionTurnState(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) string {
if s == nil {
return ""
}
key := openAICompatSessionResponseKey(c, account, promptCacheKey)
if key == "" {
return ""
}
raw, ok := s.openaiCompatSessionResponses.Load(key)
if !ok {
return ""
}
binding, ok := raw.(openAICompatSessionResponseBinding)
if !ok || strings.TrimSpace(binding.TurnState) == "" {
return ""
}
if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) {
s.openaiCompatSessionResponses.Delete(key)
return ""
}
return strings.TrimSpace(binding.TurnState)
}
func (s *OpenAIGatewayService) bindOpenAICompatSessionTurnState(_ context.Context, c *gin.Context, account *Account, promptCacheKey, turnState string) {
if s == nil {
return
}
key := openAICompatSessionResponseKey(c, account, promptCacheKey)
state := strings.TrimSpace(turnState)
if key == "" || state == "" {
return
}
binding := openAICompatSessionResponseBinding{
TurnState: state,
ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()),
}
if raw, ok := s.openaiCompatSessionResponses.Load(key); ok {
if existing, ok := raw.(openAICompatSessionResponseBinding); ok {
binding.ResponseID = existing.ResponseID
binding.ContinuationDisabled = existing.ContinuationDisabled
}
}
s.openaiCompatSessionResponses.Store(key, binding)
}