mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(gateway): resolve composite routes for websocket aliases
This commit is contained in:
@@ -294,7 +294,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
|
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
|
||||||
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
|
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
|
||||||
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
|
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
|
||||||
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
|
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver)
|
||||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||||
totpHandler := handler.NewTotpHandler(totpService)
|
totpHandler := handler.NewTotpHandler(totpService)
|
||||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
|
|
||||||
// OpenAIGatewayHandler handles OpenAI API gateway requests
|
// OpenAIGatewayHandler handles OpenAI API gateway requests
|
||||||
type OpenAIGatewayHandler struct {
|
type OpenAIGatewayHandler struct {
|
||||||
|
compositeResolver *service.CompositeRouteResolver
|
||||||
gatewayService *service.OpenAIGatewayService
|
gatewayService *service.OpenAIGatewayService
|
||||||
billingCacheService *service.BillingCacheService
|
billingCacheService *service.BillingCacheService
|
||||||
apiKeyService *service.APIKeyService
|
apiKeyService *service.APIKeyService
|
||||||
@@ -2391,7 +2392,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。
|
// 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。
|
||||||
// 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。
|
// 必须在合成路由解析和上游模型映射之前执行。
|
||||||
// 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定,
|
// 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定,
|
||||||
// 全部候选值逐一校验,任一未命中即拒绝。
|
// 全部候选值逐一校验,任一未命中即拒绝。
|
||||||
if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" {
|
if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" {
|
||||||
@@ -2400,7 +2401,21 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked))
|
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
// Keep the client model in the frame for admission, response identity and usage.
|
||||||
|
// Apply the resolved upstream model through MapRequestModel on every turn.
|
||||||
|
wsRouteModel := reqModel
|
||||||
|
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
|
||||||
|
decision, resolveErr := h.compositeResolver.Resolve(c.Request.Context(), apiKey.Group.ID, reqModel, service.CompositeRouteEndpointResponses)
|
||||||
|
if resolveErr != nil {
|
||||||
|
reqLog.Error("openai.websocket_composite_route_failed", zap.Error(resolveErr))
|
||||||
|
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Failed to resolve composite model route")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if decision.Matched {
|
||||||
|
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
|
||||||
|
wsRouteModel = decision.UpstreamModel
|
||||||
|
}
|
||||||
|
}
|
||||||
ctx = c.Request.Context()
|
ctx = c.Request.Context()
|
||||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
|
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
|
||||||
platform, ok := service.ResolvedTargetPlatformFromContext(ctx)
|
platform, ok := service.ResolvedTargetPlatformFromContext(ctx)
|
||||||
@@ -2464,8 +2479,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 解析渠道级模型映射
|
// 解析渠道级模型映射
|
||||||
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
|
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, wsRouteModel)
|
||||||
wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel)
|
wsForwardModel := openAIChannelForwardModel(channelMappingWS, wsRouteModel)
|
||||||
|
|
||||||
var currentUserRelease func()
|
var currentUserRelease func()
|
||||||
var currentAccountRelease func()
|
var currentAccountRelease func()
|
||||||
@@ -2856,7 +2871,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
model = reqModel
|
model = reqModel
|
||||||
}
|
}
|
||||||
setOpsRequestContext(c, model, true)
|
setOpsRequestContext(c, model, true)
|
||||||
mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model)
|
routeModel := model
|
||||||
|
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
|
||||||
|
// The account, target platform and route context are connection-scoped.
|
||||||
|
// A different public model needs a new connection and fresh resolution.
|
||||||
|
if model != reqModel {
|
||||||
|
return "", newOpenAIWSUnsupportedModelSwitchError(model)
|
||||||
|
}
|
||||||
|
routeModel = wsRouteModel
|
||||||
|
}
|
||||||
|
mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, routeModel)
|
||||||
mappedModelUnchanged := false
|
mappedModelUnchanged := false
|
||||||
if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn {
|
if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn {
|
||||||
mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel)
|
mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel)
|
||||||
|
|||||||
@@ -1931,7 +1931,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
|
|||||||
|
|
||||||
type openAIResponsesWSUsageLogCase struct {
|
type openAIResponsesWSUsageLogCase struct {
|
||||||
simpleModeRejectAtRead int64
|
simpleModeRejectAtRead int64
|
||||||
|
compositeResolver *service.CompositeRouteResolver
|
||||||
|
accountPlatform string
|
||||||
closeReason string
|
closeReason string
|
||||||
|
closeStatus coderws.StatusCode
|
||||||
firstPayload string
|
firstPayload string
|
||||||
// midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它
|
// midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它
|
||||||
// 回一个 response.completed,客户端按普通事件读取。
|
// 回一个 response.completed,客户端按普通事件读取。
|
||||||
@@ -2878,6 +2881,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
upstreamErrCh := make(chan error, 1)
|
upstreamErrCh := make(chan error, 1)
|
||||||
var channelSvc *service.ChannelService
|
var channelSvc *service.ChannelService
|
||||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if tc.accountPlatform == service.PlatformGrok {
|
||||||
|
payload, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
upstreamErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
upstreamPayloadCh <- payload
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = fmt.Fprintf(w, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_grok_test\",\"model\":%q,\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}}\n\n", gjson.GetBytes(payload, "model").String())
|
||||||
|
return
|
||||||
|
}
|
||||||
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{
|
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{
|
||||||
CompressionMode: coderws.CompressionContextTakeover,
|
CompressionMode: coderws.CompressionContextTakeover,
|
||||||
})
|
})
|
||||||
@@ -2945,6 +2959,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
"openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
|
"openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if tc.accountPlatform != "" {
|
||||||
|
account.Platform = tc.accountPlatform
|
||||||
|
}
|
||||||
if strings.TrimSpace(tc.ingressMode) != "" {
|
if strings.TrimSpace(tc.ingressMode) != "" {
|
||||||
account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode
|
account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode
|
||||||
}
|
}
|
||||||
@@ -3000,7 +3017,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
service.NewBillingService(cfg, nil),
|
service.NewBillingService(cfg, nil),
|
||||||
nil,
|
nil,
|
||||||
billingCacheSvc,
|
billingCacheSvc,
|
||||||
nil,
|
&compositeWSHTTPUpstream{},
|
||||||
&service.DeferredService{},
|
&service.DeferredService{},
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
@@ -3021,6 +3038,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
}
|
}
|
||||||
h := &OpenAIGatewayHandler{
|
h := &OpenAIGatewayHandler{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
compositeResolver: tc.compositeResolver,
|
||||||
gatewayService: gatewaySvc,
|
gatewayService: gatewaySvc,
|
||||||
billingCacheService: billingCacheSvc,
|
billingCacheService: billingCacheSvc,
|
||||||
apiKeyService: &service.APIKeyService{},
|
apiKeyService: &service.APIKeyService{},
|
||||||
@@ -3069,6 +3087,12 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
cancelWrite()
|
cancelWrite()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if tc.closeReason == "" {
|
||||||
|
tc.closeReason = "not available for this group"
|
||||||
|
}
|
||||||
|
if tc.closeStatus == 0 {
|
||||||
|
tc.closeStatus = coderws.StatusPolicyViolation
|
||||||
|
}
|
||||||
if tc.firstFrameCloseExpected {
|
if tc.firstFrameCloseExpected {
|
||||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
_, _, readErr := clientConn.Read(readCtx)
|
_, _, readErr := clientConn.Read(readCtx)
|
||||||
@@ -3076,12 +3100,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
require.Error(t, readErr, "first frame should have been rejected with a close")
|
require.Error(t, readErr, "first frame should have been rejected with a close")
|
||||||
var closeErr coderws.CloseError
|
var closeErr coderws.CloseError
|
||||||
require.ErrorAs(t, readErr, &closeErr)
|
require.ErrorAs(t, readErr, &closeErr)
|
||||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
status := tc.closeStatus
|
||||||
|
if status == 0 {
|
||||||
|
status = coderws.StatusPolicyViolation
|
||||||
|
}
|
||||||
|
require.Equal(t, status, closeErr.Code)
|
||||||
reason := tc.closeReason
|
reason := tc.closeReason
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = "not available for this group"
|
reason = "not available for this group"
|
||||||
}
|
}
|
||||||
require.Contains(t, closeErr.Reason, reason)
|
require.Contains(t, closeErr.Reason, reason)
|
||||||
|
require.Empty(t, upstreamPayloadCh, "rejected first frame must not reach upstream")
|
||||||
_ = clientConn.CloseNow()
|
_ = clientConn.CloseNow()
|
||||||
return openAIResponsesWSUsageLogResult{}
|
return openAIResponsesWSUsageLogResult{}
|
||||||
}
|
}
|
||||||
@@ -3115,12 +3144,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
require.Error(t, readErr, "second turn should have been rejected with a close")
|
require.Error(t, readErr, "second turn should have been rejected with a close")
|
||||||
var closeErr coderws.CloseError
|
var closeErr coderws.CloseError
|
||||||
require.ErrorAs(t, readErr, &closeErr)
|
require.ErrorAs(t, readErr, &closeErr)
|
||||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
status := tc.closeStatus
|
||||||
|
if status == 0 {
|
||||||
|
status = coderws.StatusPolicyViolation
|
||||||
|
}
|
||||||
|
require.Equal(t, status, closeErr.Code)
|
||||||
reason := tc.closeReason
|
reason := tc.closeReason
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = "not available for this group"
|
reason = "not available for this group"
|
||||||
}
|
}
|
||||||
require.Contains(t, closeErr.Reason, reason)
|
require.Contains(t, closeErr.Reason, reason)
|
||||||
|
require.Len(t, upstreamPayloadCh, turnCount-1, "rejected turn must not reach upstream")
|
||||||
_ = clientConn.CloseNow()
|
_ = clientConn.CloseNow()
|
||||||
return openAIResponsesWSUsageLogResult{}
|
return openAIResponsesWSUsageLogResult{}
|
||||||
}
|
}
|
||||||
@@ -3149,6 +3183,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tc.accountPlatform == service.PlatformGrok {
|
||||||
|
upstreamErrCh <- nil
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case upstreamErr := <-upstreamErrCh:
|
case upstreamErr := <-upstreamErrCh:
|
||||||
require.NoError(t, upstreamErr)
|
require.NoError(t, upstreamErr)
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
coderws "github.com/coder/websocket"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
type compositeWSRouteRepo struct {
|
||||||
|
service.CompositeModelRouteRepository
|
||||||
|
routes []service.CompositeModelRoute
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *compositeWSRouteRepo) ListByGroup(context.Context, int64, bool) ([]service.CompositeModelRoute, error) {
|
||||||
|
return r.routes, r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
type compositeWSHTTPUpstream struct{ service.HTTPUpstream }
|
||||||
|
|
||||||
|
func (*compositeWSHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
|
||||||
|
return http.DefaultClient.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compositeWSResolver(platform, endpoint, upstream string) *service.CompositeRouteResolver {
|
||||||
|
return service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{
|
||||||
|
GroupID: 4201, PublicModel: "public-alias", MatchType: service.CompositeRouteMatchExact,
|
||||||
|
TargetPlatform: platform, Endpoint: endpoint, UpstreamModel: upstream, Enabled: true,
|
||||||
|
}}})
|
||||||
|
}
|
||||||
|
func compositeWSGroup(models ...string) *service.Group {
|
||||||
|
g := wsAllowlistGroup(len(models) > 0, models...)
|
||||||
|
g.Platform = service.PlatformComposite
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_CompositeAlias(t *testing.T) {
|
||||||
|
for _, platform := range []string{service.PlatformOpenAI, service.PlatformGrok} {
|
||||||
|
for _, endpoint := range []string{service.CompositeRouteEndpointResponses, service.CompositeRouteEndpointAny} {
|
||||||
|
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
|
||||||
|
t.Run(platform+"/"+endpoint+"/"+mode, func(t *testing.T) {
|
||||||
|
upstream := "gpt-5.4"
|
||||||
|
if platform == service.PlatformGrok {
|
||||||
|
upstream = "grok-4.3"
|
||||||
|
}
|
||||||
|
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
|
firstPayload: `{"type":"response.create","model":"public-alias","input":"hi"}`,
|
||||||
|
secondPayload: `{"type":"response.create","input":"again"}`,
|
||||||
|
group: compositeWSGroup("public-alias"), accountPlatform: platform, ingressMode: mode,
|
||||||
|
compositeResolver: compositeWSResolver(platform, endpoint, upstream),
|
||||||
|
})
|
||||||
|
require.Len(t, got.upstreamPayloads, 2)
|
||||||
|
for i, payload := range got.upstreamPayloads {
|
||||||
|
require.Equal(t, upstream, gjson.GetBytes(payload, "model").String())
|
||||||
|
require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[i], "response.model").String())
|
||||||
|
require.Equal(t, "public-alias", got.logs[i].RequestedModel)
|
||||||
|
require.NotNil(t, got.logs[i].UpstreamModel)
|
||||||
|
require.Equal(t, upstream, *got.logs[i].UpstreamModel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_CompositeRouteRejections(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, platform, endpoint, model, reason string
|
||||||
|
repoErr error
|
||||||
|
status coderws.StatusCode
|
||||||
|
}{
|
||||||
|
{name: "disallowed platform", platform: service.PlatformAnthropic, endpoint: "responses", model: "public-alias", reason: "only supports OpenAI-compatible"},
|
||||||
|
{name: "wrong endpoint", platform: service.PlatformOpenAI, endpoint: "messages", model: "public-alias", reason: "only supports OpenAI-compatible"},
|
||||||
|
{name: "unknown alias", platform: service.PlatformOpenAI, endpoint: "responses", model: "unknown-alias", reason: "only supports OpenAI-compatible"},
|
||||||
|
{name: "resolver error", model: "gpt-5.4", repoErr: errors.New("database unavailable"), reason: "Failed to resolve composite model route", status: coderws.StatusInternalError},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
resolver := compositeWSResolver(tc.platform, tc.endpoint, "gpt-5.4")
|
||||||
|
if tc.repoErr != nil {
|
||||||
|
resolver = service.NewCompositeRouteResolver(&compositeWSRouteRepo{err: tc.repoErr})
|
||||||
|
}
|
||||||
|
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
|
firstPayload: `{"type":"response.create","model":"` + tc.model + `"}`, group: compositeWSGroup(), compositeResolver: resolver,
|
||||||
|
firstFrameCloseExpected: true, closeReason: tc.reason, closeStatus: tc.status,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_CompositeAdmissionUsesPublicModel(t *testing.T) {
|
||||||
|
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
|
firstPayload: `{"type":"response.create","model":"public-alias"}`, group: compositeWSGroup("gpt-5.4"),
|
||||||
|
compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"), firstFrameCloseExpected: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_CompositeModelSwitchRequiresReconnect(t *testing.T) {
|
||||||
|
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
|
||||||
|
for _, model := range []string{"gpt-5.4", "grok-4.3", "unknown-alias"} {
|
||||||
|
t.Run(mode+"/"+model, func(t *testing.T) {
|
||||||
|
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
|
firstPayload: `{"type":"response.create","model":"public-alias"}`,
|
||||||
|
secondPayload: `{"type":"response.create","model":"` + model + `"}`, group: compositeWSGroup(), ingressMode: mode,
|
||||||
|
compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"),
|
||||||
|
secondTurnCloseExpected: true, closeReason: "model switch requires reconnect",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_CompositeChannelBilling(t *testing.T) {
|
||||||
|
for _, source := range []string{service.BillingModelSourceRequested, service.BillingModelSourceChannelMapped, service.BillingModelSourceUpstream} {
|
||||||
|
t.Run(source, func(t *testing.T) {
|
||||||
|
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
|
firstPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`,
|
||||||
|
secondPayload: `{"type":"response.create","model":"gpt-5.6-sol"}`,
|
||||||
|
group: compositeWSGroup("gpt-5.6-sol"),
|
||||||
|
compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{routes: []service.CompositeModelRoute{{PublicModel: "gpt-5.6-sol", MatchType: service.CompositeRouteMatchExact, TargetPlatform: service.PlatformOpenAI, Endpoint: service.CompositeRouteEndpointResponses, UpstreamModel: "route-target"}}}),
|
||||||
|
channelMapping: map[string]string{"route-target": "gpt-5.4"}, billingModelSource: source,
|
||||||
|
accountModelMapping: map[string]any{"gpt-5.4": "gpt-5.4"},
|
||||||
|
})
|
||||||
|
for i, log := range got.logs {
|
||||||
|
require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamPayloads[i], "model").String())
|
||||||
|
require.Equal(t, "gpt-5.6-sol", log.RequestedModel)
|
||||||
|
require.Equal(t, "gpt-5.6-sol", log.Model)
|
||||||
|
if source == service.BillingModelSourceRequested {
|
||||||
|
require.InDelta(t, 40e-6, log.TotalCost, 1e-12)
|
||||||
|
} else {
|
||||||
|
require.InDelta(t, 20e-6, log.TotalCost, 1e-12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_CompositeDetectorFallback(t *testing.T) {
|
||||||
|
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
|
firstPayload: `{"type":"response.create","model":"gpt-5.4"}`, group: compositeWSGroup(),
|
||||||
|
compositeResolver: service.NewCompositeRouteResolver(&compositeWSRouteRepo{}),
|
||||||
|
})
|
||||||
|
require.Equal(t, "gpt-5.4", gjson.GetBytes(got.upstreamFirstPayload, "model").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesWebSocket_CompositeSessionModelSwitchRequiresReconnect(t *testing.T) {
|
||||||
|
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||||
|
firstPayload: `{"type":"response.create","model":"public-alias"}`,
|
||||||
|
midPayload: `{"type":"session.update","session":{"model":"grok-4.3"}}`,
|
||||||
|
secondPayload: `{"type":"response.create"}`, group: compositeWSGroup(),
|
||||||
|
compositeResolver: compositeWSResolver(service.PlatformOpenAI, "responses", "gpt-5.4"),
|
||||||
|
secondTurnCloseExpected: true, closeReason: "model switch requires reconnect",
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -130,10 +130,12 @@ func ProvideOpenAIGatewayHandler(
|
|||||||
grokQuotaService *service.GrokQuotaService,
|
grokQuotaService *service.GrokQuotaService,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
coordinator *securityaudit.Coordinator,
|
coordinator *securityaudit.Coordinator,
|
||||||
|
compositeResolver *service.CompositeRouteResolver,
|
||||||
) *OpenAIGatewayHandler {
|
) *OpenAIGatewayHandler {
|
||||||
gatewayService.SetPluginManager(pluginManager)
|
gatewayService.SetPluginManager(pluginManager)
|
||||||
h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService,
|
h := NewOpenAIGatewayHandler(gatewayService, concurrencyService, billingCacheService, apiKeyService,
|
||||||
usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg)
|
usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, cfg)
|
||||||
|
h.compositeResolver = compositeResolver
|
||||||
h.securityAuditCoordinator = coordinator
|
h.securityAuditCoordinator = coordinator
|
||||||
h.grokMediaEligibilityProber = grokQuotaService
|
h.grokMediaEligibilityProber = grokQuotaService
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -585,7 +585,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
|||||||
bridgeAccountFailoverInputExists := false
|
bridgeAccountFailoverInputExists := false
|
||||||
for turn := 1; ; turn++ {
|
for turn := 1; ; turn++ {
|
||||||
if turn > 1 && hooks != nil && hooks.BeforeRequest != nil {
|
if turn > 1 && hooks != nil && hooks.BeforeRequest != nil {
|
||||||
if err := hooks.BeforeRequest(turn, currentBridgePayload.payloadRaw, currentBridgePayload.originalModel); err != nil {
|
if err := hooks.BeforeRequest(turn, currentBridgePayload.rawForHash, currentBridgePayload.originalModel); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1272,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPayload := firstPayload.payloadRaw
|
currentPayload := firstPayload.payloadRaw
|
||||||
|
// Admission hooks must see client model candidates before upstream mapping.
|
||||||
|
currentClientPayload := firstPayload.rawForHash
|
||||||
currentOriginalModel := firstPayload.originalModel
|
currentOriginalModel := firstPayload.originalModel
|
||||||
currentImageBillingModel := firstPayload.imageBillingModel
|
currentImageBillingModel := firstPayload.imageBillingModel
|
||||||
currentImageSizeTier := firstPayload.imageSizeTier
|
currentImageSizeTier := firstPayload.imageSizeTier
|
||||||
@@ -1460,7 +1462,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
|||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil {
|
if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil {
|
||||||
if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil {
|
if err := hooks.BeforeRequest(turn, currentClientPayload, currentOriginalModel); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1941,6 +1943,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentPayload = nextPayload.payloadRaw
|
currentPayload = nextPayload.payloadRaw
|
||||||
|
currentClientPayload = nextPayload.rawForHash
|
||||||
currentOriginalModel = nextPayload.originalModel
|
currentOriginalModel = nextPayload.originalModel
|
||||||
currentImageBillingModel = nextPayload.imageBillingModel
|
currentImageBillingModel = nextPayload.imageBillingModel
|
||||||
currentImageSizeTier = nextPayload.imageSizeTier
|
currentImageSizeTier = nextPayload.imageSizeTier
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ payloads, and apply the configured `upstream_model` before dispatch.
|
|||||||
Codex model manifest requests reuse the existing OpenAI account selection and
|
Codex model manifest requests reuse the existing OpenAI account selection and
|
||||||
failover path within the Composite group.
|
failover path within the Composite group.
|
||||||
|
|
||||||
|
Responses WebSocket resolves the first `response.create` model in the
|
||||||
|
`responses` route domain (including `any` routes). Only OpenAI and Grok targets
|
||||||
|
are supported. The public model remains the admission and response identity;
|
||||||
|
the route's upstream model is applied before channel/account mapping. Subsequent
|
||||||
|
turns can repeat or omit the public model. Changing it requires reconnecting so
|
||||||
|
the gateway can resolve the route and select an account again.
|
||||||
|
|
||||||
## Built-In Detection
|
## Built-In Detection
|
||||||
|
|
||||||
Composite routing detects common public model IDs and provider-prefixed IDs:
|
Composite routing detects common public model IDs and provider-prefixed IDs:
|
||||||
|
|||||||
Reference in New Issue
Block a user