feat(openai): add Live gateway support
This commit is contained in:
1 parent
37ed639d1e
commit
e6eb23eaac
55 files changed
+2607
-30
No files matched your search
@@ -125,6 +125,7 @@ type CreateGroupRequest struct {
|
||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
AllowLive bool `json:"allow_live"`
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
@@ -183,6 +184,7 @@ type UpdateGroupRequest struct {
|
||||
SupportedModelScopes *[]string `json:"supported_model_scopes"`
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
|
||||
AllowLive *bool `json:"allow_live"`
|
||||
RequireOAuthOnly *bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
@@ -499,6 +501,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
MCPXMLInject: req.MCPXMLInject,
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
@@ -617,6 +620,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
MCPXMLInject: req.MCPXMLInject,
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
|
||||
@@ -204,6 +204,7 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
FallbackGroupID: g.FallbackGroupID,
|
||||
FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest,
|
||||
AllowMessagesDispatch: g.AllowMessagesDispatch,
|
||||
AllowLive: g.AllowLive,
|
||||
RequireOAuthOnly: g.RequireOAuthOnly,
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
RPMLimit: g.RPMLimit,
|
||||
|
||||
@@ -132,6 +132,8 @@ type Group struct {
|
||||
|
||||
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
// OpenAI Live 接口开关
|
||||
AllowLive bool `json:"allow_live"`
|
||||
|
||||
// 账号过滤控制(仅 OpenAI/Antigravity 平台有效)
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *OpenAIGatewayHandler) Live(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||
return
|
||||
}
|
||||
if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformOpenAI {
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live is not supported for this platform")
|
||||
return
|
||||
}
|
||||
if !liveEnabledForAPIKey(apiKey) {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", "Live is not enabled for this group")
|
||||
return
|
||||
}
|
||||
request, err := parseLiveCallRequest(c)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String())
|
||||
reqLog := requestLogger(
|
||||
c,
|
||||
"handler.openai_gateway.live",
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", apiKey.ID),
|
||||
zap.Any("group_id", apiKey.GroupID),
|
||||
)
|
||||
if decision := h.checkSecurityAudit(
|
||||
c,
|
||||
reqLog,
|
||||
apiKey,
|
||||
subject,
|
||||
service.ContentModerationProtocolOpenAIResponses,
|
||||
model,
|
||||
request.Session,
|
||||
); decision != nil && !decision.AllowNextStage {
|
||||
h.openAISecurityAuditError(c, decision)
|
||||
return
|
||||
}
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
if h.billingCacheService == nil {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Billing service unavailable")
|
||||
return
|
||||
}
|
||||
if err := h.billingCacheService.CheckBillingEligibility(
|
||||
c.Request.Context(),
|
||||
apiKey.User,
|
||||
apiKey,
|
||||
apiKey.Group,
|
||||
subscription,
|
||||
service.QuotaPlatform(c.Request.Context(), apiKey),
|
||||
); err != nil {
|
||||
status, code, message, retryAfter := billingErrorDetails(err)
|
||||
if retryAfter > 0 {
|
||||
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
h.errorResponse(c, status, code, message)
|
||||
return
|
||||
}
|
||||
|
||||
userRelease, acquired, err := h.concurrencyHelper.TryAcquireUserSlot(
|
||||
c.Request.Context(),
|
||||
subject.UserID,
|
||||
subject.Concurrency,
|
||||
)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Live concurrency unavailable")
|
||||
return
|
||||
}
|
||||
if !acquired {
|
||||
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Live concurrency limit reached")
|
||||
return
|
||||
}
|
||||
defer userRelease()
|
||||
|
||||
identity := liveCallIdentity(c, apiKey, subject.UserID, subscription)
|
||||
created, err := h.gatewayService.CreateLiveCall(c.Request.Context(), request, identity, subject.Concurrency)
|
||||
if err != nil {
|
||||
h.writeLiveCreateError(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Location", liveSidebandLocation(c.FullPath(), created.CallID))
|
||||
c.Data(http.StatusOK, "application/sdp", created.SDP)
|
||||
}
|
||||
|
||||
func parseLiveCallRequest(c *gin.Context) (*service.LiveCallRequest, error) {
|
||||
contentType := strings.ToLower(c.GetHeader("Content-Type"))
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
sdp := c.PostForm("sdp")
|
||||
session := json.RawMessage(c.PostForm("session"))
|
||||
request := &service.LiveCallRequest{SDP: sdp, Session: session}
|
||||
if err := service.ValidateLiveCallRequest(request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
var request service.LiveCallRequest
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return nil, errors.New("request body must be valid JSON")
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("request body must contain one JSON object")
|
||||
}
|
||||
if err := service.ValidateLiveCallRequest(&request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &request, nil
|
||||
}
|
||||
|
||||
func liveSidebandLocation(fullPath, callID string) string {
|
||||
prefix := "/v1/live/"
|
||||
if strings.HasPrefix(fullPath, "/backend-api/codex/") {
|
||||
prefix = "/backend-api/codex/"
|
||||
}
|
||||
return prefix + url.PathEscape(callID)
|
||||
}
|
||||
|
||||
func liveCallIdentity(
|
||||
c *gin.Context,
|
||||
apiKey *service.APIKey,
|
||||
userID int64,
|
||||
subscription *service.UserSubscription,
|
||||
) service.LiveCallIdentity {
|
||||
var subscriptionID *int64
|
||||
if subscription != nil {
|
||||
value := subscription.ID
|
||||
subscriptionID = &value
|
||||
}
|
||||
return service.LiveCallIdentity{
|
||||
APIKeyID: apiKey.ID,
|
||||
UserID: userID,
|
||||
GroupID: apiKey.GroupID,
|
||||
SubscriptionID: subscriptionID,
|
||||
UserAgent: c.GetHeader("User-Agent"),
|
||||
IPAddress: ip.GetClientIP(c),
|
||||
InboundEndpoint: GetInboundEndpoint(c),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) writeLiveCreateError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrLiveConcurrencyFull):
|
||||
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Live concurrency limit reached")
|
||||
case errors.Is(err, service.ErrLiveUnavailable):
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Live is unavailable")
|
||||
default:
|
||||
var upstreamErr *service.UpstreamFailoverError
|
||||
if errors.As(err, &upstreamErr) && upstreamErr.StatusCode >= 400 && upstreamErr.StatusCode < 500 {
|
||||
h.errorResponse(c, upstreamErr.StatusCode, "invalid_request_error", "Live upstream rejected the request")
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusBadGateway, "api_error", "Live upstream request failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||
return
|
||||
}
|
||||
if !liveEnabledForAPIKey(apiKey) {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", "Live is not enabled for this group")
|
||||
return
|
||||
}
|
||||
identity := service.LiveCallIdentity{
|
||||
APIKeyID: apiKey.ID,
|
||||
UserID: subject.UserID,
|
||||
GroupID: apiKey.GroupID,
|
||||
}
|
||||
record, err := h.gatewayService.GetLiveCallForIdentity(c.Request.Context(), c.Param("call_id"), identity)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLiveIdentityMismatch) {
|
||||
h.errorResponse(c, http.StatusForbidden, "permission_error", "Live call belongs to another identity")
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live call not found")
|
||||
return
|
||||
}
|
||||
downstream, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer downstream.CloseNow()
|
||||
if err := h.gatewayService.ProxyLiveSideband(c.Request.Context(), record, downstream); err != nil {
|
||||
_ = downstream.Close(coderws.StatusInternalError, "live sideband closed")
|
||||
return
|
||||
}
|
||||
_ = downstream.Close(coderws.StatusNormalClosure, "")
|
||||
}
|
||||
|
||||
func liveEnabledForAPIKey(apiKey *service.APIKey) bool {
|
||||
return apiKey != nil &&
|
||||
apiKey.Group != nil &&
|
||||
apiKey.Group.Platform == service.PlatformOpenAI &&
|
||||
apiKey.Group.AllowLive
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseLiveCallRequestMultipartPreservesSession(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
session := `{"model":"gpt-live-test","delegation":{"type":"client"},"instructions":"你好"}`
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
require.NoError(t, writer.WriteField("sdp", "v=0\r\n"))
|
||||
require.NoError(t, writer.WriteField("session", session))
|
||||
require.NoError(t, writer.Close())
|
||||
|
||||
request := httptest.NewRequest("POST", "/v1/live", &body)
|
||||
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
|
||||
parsed, err := parseLiveCallRequest(context)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "v=0\r\n", parsed.SDP)
|
||||
require.JSONEq(t, session, string(parsed.Session))
|
||||
require.Equal(t, "client", jsonPathString(t, parsed.Session, "delegation", "type"))
|
||||
}
|
||||
|
||||
func TestParseLiveCallRequestJSONPreservesSessionWithoutDelegation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := `{"sdp":"v=0\\r\\n","session":{"model":"gpt-live-test","instructions":"standalone"}}`
|
||||
request := httptest.NewRequest("POST", "/backend-api/codex/realtime/calls", bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
|
||||
parsed, err := parseLiveCallRequest(context)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(parsed.Session), "delegation")
|
||||
require.Equal(t, "standalone", jsonPathString(t, parsed.Session, "instructions"))
|
||||
}
|
||||
|
||||
func TestParseLiveCallRequestRejectsInvalidJSONShape(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
testCases := []string{
|
||||
`{"session":{"type":"quicksilver"}}`,
|
||||
`{"sdp":"v=0\\r\\n","session":[]}`,
|
||||
`{"sdp":"v=0\\r\\n","session":null}`,
|
||||
`{"sdp":"v=0\\r\\n","session":{"type":"quicksilver"}} {}`,
|
||||
}
|
||||
for _, body := range testCases {
|
||||
request := httptest.NewRequest("POST", "/backend-api/codex/realtime/calls", bytes.NewBufferString(body))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
context, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
context.Request = request
|
||||
_, err := parseLiveCallRequest(context)
|
||||
require.Error(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveSidebandLocationMatchesCreateRoute(t *testing.T) {
|
||||
require.Equal(t, "/v1/live/call_123", liveSidebandLocation("/v1/live", "call_123"))
|
||||
require.Equal(
|
||||
t,
|
||||
"/backend-api/codex/call_123",
|
||||
liveSidebandLocation("/backend-api/codex/realtime/calls", "call_123"),
|
||||
)
|
||||
}
|
||||
|
||||
func TestLiveEnabledForAPIKey(t *testing.T) {
|
||||
require.False(t, liveEnabledForAPIKey(nil))
|
||||
require.False(t, liveEnabledForAPIKey(&service.APIKey{}))
|
||||
require.False(t, liveEnabledForAPIKey(&service.APIKey{
|
||||
Group: &service.Group{Platform: service.PlatformOpenAI},
|
||||
}))
|
||||
require.False(t, liveEnabledForAPIKey(&service.APIKey{
|
||||
Group: &service.Group{Platform: service.PlatformAnthropic, AllowLive: true},
|
||||
}))
|
||||
require.True(t, liveEnabledForAPIKey(&service.APIKey{
|
||||
Group: &service.Group{Platform: service.PlatformOpenAI, AllowLive: true},
|
||||
}))
|
||||
}
|
||||
|
||||
func jsonPathString(t *testing.T, raw json.RawMessage, keys ...string) string {
|
||||
t.Helper()
|
||||
var value any
|
||||
require.NoError(t, json.Unmarshal(raw, &value))
|
||||
current := value
|
||||
for _, key := range keys {
|
||||
object, ok := current.(map[string]any)
|
||||
require.True(t, ok)
|
||||
current = object[key]
|
||||
}
|
||||
result, ok := current.(string)
|
||||
require.True(t, ok)
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user