fix(openai): gate routing by endpoint capability
This commit is contained in:
parent
8c1a07852c
commit
ed1b57c597
@ -127,7 +127,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
reqLog.Debug("openai_chat_completions.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
reqLog.Debug("openai_chat_completions.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
||||||
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
|
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
apiKey.GroupID,
|
apiKey.GroupID,
|
||||||
"",
|
"",
|
||||||
@ -135,6 +135,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
|||||||
reqModel,
|
reqModel,
|
||||||
failedAccountIDs,
|
failedAccountIDs,
|
||||||
service.OpenAIUpstreamTransportAny,
|
service.OpenAIUpstreamTransportAny,
|
||||||
|
service.OpenAIEndpointCapabilityChatCompletions,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -107,7 +107,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
|||||||
routingStart := time.Now()
|
routingStart := time.Now()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
selection, _, err := h.gatewayService.SelectAccountWithScheduler(
|
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
apiKey.GroupID,
|
apiKey.GroupID,
|
||||||
"",
|
"",
|
||||||
@ -115,6 +115,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
|||||||
reqModel,
|
reqModel,
|
||||||
failedAccountIDs,
|
failedAccountIDs,
|
||||||
service.OpenAIUpstreamTransportHTTPSSE,
|
service.OpenAIUpstreamTransportHTTPSSE,
|
||||||
|
service.OpenAIEndpointCapabilityEmbeddings,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -140,13 +141,6 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
account := selection.Account
|
account := selection.Account
|
||||||
if account.Type != service.AccountTypeAPIKey {
|
|
||||||
if selection.ReleaseFunc != nil {
|
|
||||||
selection.ReleaseFunc()
|
|
||||||
}
|
|
||||||
failedAccountIDs[account.ID] = struct{}{}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||||
|
|
||||||
accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &streamStarted, reqLog)
|
accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &streamStarted, reqLog)
|
||||||
|
|||||||
@ -266,7 +266,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
for {
|
for {
|
||||||
// Select account supporting the requested model
|
// Select account supporting the requested model
|
||||||
reqLog.Debug("openai.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
reqLog.Debug("openai.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
||||||
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
|
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
apiKey.GroupID,
|
apiKey.GroupID,
|
||||||
previousResponseID,
|
previousResponseID,
|
||||||
@ -274,6 +274,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
|||||||
reqModel,
|
reqModel,
|
||||||
failedAccountIDs,
|
failedAccountIDs,
|
||||||
service.OpenAIUpstreamTransportAny,
|
service.OpenAIUpstreamTransportAny,
|
||||||
|
service.OpenAIEndpointCapabilityChatCompletions,
|
||||||
requireCompact,
|
requireCompact,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -675,7 +676,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
|||||||
currentRoutingModel = effectiveMappedModel
|
currentRoutingModel = effectiveMappedModel
|
||||||
}
|
}
|
||||||
reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
reqLog.Debug("openai_messages.account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
||||||
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
|
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
apiKey.GroupID,
|
apiKey.GroupID,
|
||||||
"", // no previous_response_id
|
"", // no previous_response_id
|
||||||
@ -683,6 +684,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
|||||||
currentRoutingModel,
|
currentRoutingModel,
|
||||||
failedAccountIDs,
|
failedAccountIDs,
|
||||||
service.OpenAIUpstreamTransportAny,
|
service.OpenAIUpstreamTransportAny,
|
||||||
|
service.OpenAIEndpointCapabilityChatCompletions,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1273,7 +1275,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
|
|
||||||
for {
|
for {
|
||||||
reqLog.Debug("openai.websocket_account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
reqLog.Debug("openai.websocket_account_selecting", zap.Int("excluded_account_count", len(failedAccountIDs)))
|
||||||
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithScheduler(
|
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||||
ctx,
|
ctx,
|
||||||
apiKey.GroupID,
|
apiKey.GroupID,
|
||||||
previousResponseID,
|
previousResponseID,
|
||||||
@ -1281,6 +1283,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
|||||||
reqModel,
|
reqModel,
|
||||||
failedAccountIDs,
|
failedAccountIDs,
|
||||||
service.OpenAIUpstreamTransportResponsesWebsocketV2,
|
service.OpenAIUpstreamTransportResponsesWebsocketV2,
|
||||||
|
service.OpenAIEndpointCapabilityChatCompletions,
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -66,6 +66,15 @@ type Account struct {
|
|||||||
modelMappingCacheRawSig uint64
|
modelMappingCacheRawSig uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OpenAIEndpointCapability string
|
||||||
|
|
||||||
|
const (
|
||||||
|
OpenAIEndpointCapabilityChatCompletions OpenAIEndpointCapability = "chat_completions"
|
||||||
|
OpenAIEndpointCapabilityEmbeddings OpenAIEndpointCapability = "embeddings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const openAIEndpointCapabilitiesCredentialKey = "openai_capabilities"
|
||||||
|
|
||||||
type TempUnschedulableRule struct {
|
type TempUnschedulableRule struct {
|
||||||
ErrorCode int `json:"error_code"`
|
ErrorCode int `json:"error_code"`
|
||||||
Keywords []string `json:"keywords"`
|
Keywords []string `json:"keywords"`
|
||||||
@ -1122,6 +1131,80 @@ func (a *Account) GetOpenAISessionID() string {
|
|||||||
return strings.TrimSpace(a.GetExtraString("openai_session_id"))
|
return strings.TrimSpace(a.GetExtraString("openai_session_id"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapability) bool {
|
||||||
|
if a == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if capability == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !a.IsOpenAI() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch capability {
|
||||||
|
case OpenAIEndpointCapabilityChatCompletions:
|
||||||
|
case OpenAIEndpointCapabilityEmbeddings:
|
||||||
|
if a.Type != AccountTypeAPIKey {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
configured, found := a.openAIEndpointCapabilitySet()
|
||||||
|
if !found {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return configured[string(capability)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Account) openAIEndpointCapabilitySet() (map[string]bool, bool) {
|
||||||
|
if a == nil || a.Credentials == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
raw, found := a.Credentials[openAIEndpointCapabilitiesCredentialKey]
|
||||||
|
if !found || raw == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]bool)
|
||||||
|
add := func(value string) {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result[value] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch capabilities := raw.(type) {
|
||||||
|
case []any:
|
||||||
|
for _, item := range capabilities {
|
||||||
|
if value, ok := item.(string); ok {
|
||||||
|
add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []string:
|
||||||
|
for _, value := range capabilities {
|
||||||
|
add(value)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for key, value := range capabilities {
|
||||||
|
enabled, ok := value.(bool)
|
||||||
|
if ok && enabled {
|
||||||
|
add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case map[string]bool:
|
||||||
|
for key, enabled := range capabilities {
|
||||||
|
if enabled {
|
||||||
|
add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapability) bool {
|
func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapability) bool {
|
||||||
if !a.IsOpenAI() {
|
if !a.IsOpenAI() {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@ -44,6 +44,7 @@ type OpenAIAccountScheduleRequest struct {
|
|||||||
PreviousResponseID string
|
PreviousResponseID string
|
||||||
RequestedModel string
|
RequestedModel string
|
||||||
RequiredTransport OpenAIUpstreamTransport
|
RequiredTransport OpenAIUpstreamTransport
|
||||||
|
RequiredCapability OpenAIEndpointCapability
|
||||||
RequiredImageCapability OpenAIImagesCapability
|
RequiredImageCapability OpenAIImagesCapability
|
||||||
RequireCompact bool
|
RequireCompact bool
|
||||||
ExcludedIDs map[int64]struct{}
|
ExcludedIDs map[int64]struct{}
|
||||||
@ -263,12 +264,13 @@ func (s *defaultOpenAIAccountScheduler) Select(
|
|||||||
|
|
||||||
previousResponseID := strings.TrimSpace(req.PreviousResponseID)
|
previousResponseID := strings.TrimSpace(req.PreviousResponseID)
|
||||||
if previousResponseID != "" {
|
if previousResponseID != "" {
|
||||||
selection, err := s.service.SelectAccountByPreviousResponseID(
|
selection, err := s.service.selectAccountByPreviousResponseIDForCapability(
|
||||||
ctx,
|
ctx,
|
||||||
req.GroupID,
|
req.GroupID,
|
||||||
previousResponseID,
|
previousResponseID,
|
||||||
req.RequestedModel,
|
req.RequestedModel,
|
||||||
req.ExcludedIDs,
|
req.ExcludedIDs,
|
||||||
|
req.RequiredCapability,
|
||||||
req.RequireCompact,
|
req.RequireCompact,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -363,7 +365,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash(
|
|||||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.RequestedModel, req.RequireCompact)
|
account = s.service.recheckSelectedOpenAIAccountFromDB(ctx, account, req.RequestedModel, req.RequireCompact, req.RequiredCapability)
|
||||||
if account == nil || !s.isAccountTransportCompatible(account, req.RequiredTransport) {
|
if account == nil || !s.isAccountTransportCompatible(account, req.RequiredTransport) {
|
||||||
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
_ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -791,11 +793,11 @@ func (s *defaultOpenAIAccountScheduler) tryAcquireOpenAISelectionOrder(
|
|||||||
compactBlocked := false
|
compactBlocked := false
|
||||||
for i := 0; i < len(selectionOrder); i++ {
|
for i := 0; i < len(selectionOrder); i++ {
|
||||||
candidate := selectionOrder[i]
|
candidate := selectionOrder[i]
|
||||||
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false)
|
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false, req.RequiredCapability)
|
||||||
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false)
|
fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false, req.RequiredCapability)
|
||||||
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -930,11 +932,11 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance(
|
|||||||
cfg := s.service.schedulingConfig()
|
cfg := s.service.schedulingConfig()
|
||||||
// WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。
|
// WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。
|
||||||
for _, candidate := range selectionOrder {
|
for _, candidate := range selectionOrder {
|
||||||
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false)
|
fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false, req.RequiredCapability)
|
||||||
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false)
|
fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false, req.RequiredCapability)
|
||||||
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -981,7 +983,7 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(ctx context.C
|
|||||||
s.service.isUpstreamModelRestrictedByChannel(ctx, *req.GroupID, account, req.RequestedModel, req.RequireCompact) {
|
s.service.isUpstreamModelRestrictedByChannel(ctx, *req.GroupID, account, req.RequestedModel, req.RequireCompact) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return account.SupportsOpenAIImageCapability(req.RequiredImageCapability)
|
return accountSupportsOpenAICapabilities(account, req.RequiredCapability, req.RequiredImageCapability)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *defaultOpenAIAccountScheduler) ReportResult(accountID int64, success bool, firstTokenMs *int) {
|
func (s *defaultOpenAIAccountScheduler) ReportResult(accountID int64, success bool, firstTokenMs *int) {
|
||||||
@ -1104,7 +1106,21 @@ func (s *OpenAIGatewayService) SelectAccountWithScheduler(
|
|||||||
requiredTransport OpenAIUpstreamTransport,
|
requiredTransport OpenAIUpstreamTransport,
|
||||||
requireCompact bool,
|
requireCompact bool,
|
||||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||||
return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, "", requireCompact)
|
return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, "", "", requireCompact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenAIGatewayService) SelectAccountWithSchedulerForCapability(
|
||||||
|
ctx context.Context,
|
||||||
|
groupID *int64,
|
||||||
|
previousResponseID string,
|
||||||
|
sessionHash string,
|
||||||
|
requestedModel string,
|
||||||
|
excludedIDs map[int64]struct{},
|
||||||
|
requiredTransport OpenAIUpstreamTransport,
|
||||||
|
requiredCapability OpenAIEndpointCapability,
|
||||||
|
requireCompact bool,
|
||||||
|
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||||
|
return s.selectAccountWithScheduler(ctx, groupID, previousResponseID, sessionHash, requestedModel, excludedIDs, requiredTransport, requiredCapability, "", requireCompact)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages(
|
func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages(
|
||||||
@ -1115,13 +1131,13 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages(
|
|||||||
excludedIDs map[int64]struct{},
|
excludedIDs map[int64]struct{},
|
||||||
requiredCapability OpenAIImagesCapability,
|
requiredCapability OpenAIImagesCapability,
|
||||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||||
selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, requiredCapability, false)
|
selection, decision, err := s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", requiredCapability, false)
|
||||||
if err == nil && selection != nil && selection.Account != nil {
|
if err == nil && selection != nil && selection.Account != nil {
|
||||||
return selection, decision, nil
|
return selection, decision, nil
|
||||||
}
|
}
|
||||||
// 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basic(OAuth 账号)
|
// 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basic(OAuth 账号)
|
||||||
if requiredCapability == OpenAIImagesCapabilityNative {
|
if requiredCapability == OpenAIImagesCapabilityNative {
|
||||||
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic, false)
|
return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, "", OpenAIImagesCapabilityBasic, false)
|
||||||
}
|
}
|
||||||
return selection, decision, err
|
return selection, decision, err
|
||||||
}
|
}
|
||||||
@ -1134,6 +1150,7 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
|||||||
requestedModel string,
|
requestedModel string,
|
||||||
excludedIDs map[int64]struct{},
|
excludedIDs map[int64]struct{},
|
||||||
requiredTransport OpenAIUpstreamTransport,
|
requiredTransport OpenAIUpstreamTransport,
|
||||||
|
requiredCapability OpenAIEndpointCapability,
|
||||||
requiredImageCapability OpenAIImagesCapability,
|
requiredImageCapability OpenAIImagesCapability,
|
||||||
requireCompact bool,
|
requireCompact bool,
|
||||||
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
|
||||||
@ -1144,14 +1161,14 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
|||||||
if requiredTransport == OpenAIUpstreamTransportAny || requiredTransport == OpenAIUpstreamTransportHTTPSSE {
|
if requiredTransport == OpenAIUpstreamTransportAny || requiredTransport == OpenAIUpstreamTransportHTTPSSE {
|
||||||
effectiveExcludedIDs := cloneExcludedAccountIDs(excludedIDs)
|
effectiveExcludedIDs := cloneExcludedAccountIDs(excludedIDs)
|
||||||
for {
|
for {
|
||||||
selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, effectiveExcludedIDs, requireCompact)
|
selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, effectiveExcludedIDs, requireCompact, requiredCapability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, decision, err
|
return nil, decision, err
|
||||||
}
|
}
|
||||||
if selection == nil || selection.Account == nil {
|
if selection == nil || selection.Account == nil {
|
||||||
return selection, decision, nil
|
return selection, decision, nil
|
||||||
}
|
}
|
||||||
if selection.Account.SupportsOpenAIImageCapability(requiredImageCapability) {
|
if accountSupportsOpenAICapabilities(selection.Account, requiredCapability, requiredImageCapability) {
|
||||||
return selection, decision, nil
|
return selection, decision, nil
|
||||||
}
|
}
|
||||||
if selection.ReleaseFunc != nil {
|
if selection.ReleaseFunc != nil {
|
||||||
@ -1169,14 +1186,15 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
|||||||
|
|
||||||
effectiveExcludedIDs := cloneExcludedAccountIDs(excludedIDs)
|
effectiveExcludedIDs := cloneExcludedAccountIDs(excludedIDs)
|
||||||
for {
|
for {
|
||||||
selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, effectiveExcludedIDs, requireCompact)
|
selection, err := s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, effectiveExcludedIDs, requireCompact, requiredCapability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, decision, err
|
return nil, decision, err
|
||||||
}
|
}
|
||||||
if selection == nil || selection.Account == nil {
|
if selection == nil || selection.Account == nil {
|
||||||
return selection, decision, nil
|
return selection, decision, nil
|
||||||
}
|
}
|
||||||
if s.isOpenAIAccountTransportCompatible(selection.Account, requiredTransport) {
|
if s.isOpenAIAccountTransportCompatible(selection.Account, requiredTransport) &&
|
||||||
|
accountSupportsOpenAICapabilities(selection.Account, requiredCapability, requiredImageCapability) {
|
||||||
return selection, decision, nil
|
return selection, decision, nil
|
||||||
}
|
}
|
||||||
if selection.ReleaseFunc != nil {
|
if selection.ReleaseFunc != nil {
|
||||||
@ -1213,12 +1231,21 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler(
|
|||||||
PreviousResponseID: previousResponseID,
|
PreviousResponseID: previousResponseID,
|
||||||
RequestedModel: requestedModel,
|
RequestedModel: requestedModel,
|
||||||
RequiredTransport: requiredTransport,
|
RequiredTransport: requiredTransport,
|
||||||
|
RequiredCapability: requiredCapability,
|
||||||
RequiredImageCapability: requiredImageCapability,
|
RequiredImageCapability: requiredImageCapability,
|
||||||
RequireCompact: requireCompact,
|
RequireCompact: requireCompact,
|
||||||
ExcludedIDs: excludedIDs,
|
ExcludedIDs: excludedIDs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func accountSupportsOpenAICapabilities(account *Account, requiredCapability OpenAIEndpointCapability, requiredImageCapability OpenAIImagesCapability) bool {
|
||||||
|
if account == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return account.SupportsOpenAIEndpointCapability(requiredCapability) &&
|
||||||
|
account.SupportsOpenAIImageCapability(requiredImageCapability)
|
||||||
|
}
|
||||||
|
|
||||||
func cloneExcludedAccountIDs(excludedIDs map[int64]struct{}) map[int64]struct{} {
|
func cloneExcludedAccountIDs(excludedIDs map[int64]struct{}) map[int64]struct{} {
|
||||||
if len(excludedIDs) == 0 {
|
if len(excludedIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -393,6 +393,64 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_Require
|
|||||||
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_EmbeddingsSkipsChatOnlyAccount(t *testing.T) {
|
||||||
|
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
groupID := int64(10110)
|
||||||
|
accounts := []Account{
|
||||||
|
{
|
||||||
|
ID: 36031,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
Concurrency: 1,
|
||||||
|
Priority: 0,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 36032,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
Concurrency: 1,
|
||||||
|
Priority: 5,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions", "embeddings"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Gateway.Scheduling.LoadBatchEnabled = false
|
||||||
|
svc := &OpenAIGatewayService{
|
||||||
|
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||||
|
cache: &schedulerTestGatewayCache{},
|
||||||
|
cfg: cfg,
|
||||||
|
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
selection, decision, err := svc.SelectAccountWithSchedulerForCapability(
|
||||||
|
ctx,
|
||||||
|
&groupID,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"text-embedding-3-small",
|
||||||
|
nil,
|
||||||
|
OpenAIUpstreamTransportHTTPSSE,
|
||||||
|
OpenAIEndpointCapabilityEmbeddings,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, selection)
|
||||||
|
require.NotNil(t, selection.Account)
|
||||||
|
require.Equal(t, int64(36032), selection.Account.ID)
|
||||||
|
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPreviousResponseRouting(t *testing.T) {
|
func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPreviousResponseRouting(t *testing.T) {
|
||||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||||
|
|
||||||
@ -458,6 +516,141 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPrev
|
|||||||
require.True(t, decision.StickyPreviousHit)
|
require.True(t, decision.StickyPreviousHit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAIGatewayService_SelectAccountWithScheduler_Enabled_EmbeddingsSkipsChatOnlyAccount(t *testing.T) {
|
||||||
|
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
groupID := int64(10111)
|
||||||
|
accounts := []Account{
|
||||||
|
{
|
||||||
|
ID: 37011,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
Concurrency: 1,
|
||||||
|
Priority: 0,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 37012,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
Concurrency: 1,
|
||||||
|
Priority: 5,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions", "embeddings"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Gateway.Scheduling.LoadBatchEnabled = false
|
||||||
|
svc := &OpenAIGatewayService{
|
||||||
|
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||||
|
cache: &schedulerTestGatewayCache{},
|
||||||
|
cfg: cfg,
|
||||||
|
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||||
|
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
selection, decision, err := svc.SelectAccountWithSchedulerForCapability(
|
||||||
|
ctx,
|
||||||
|
&groupID,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"text-embedding-3-small",
|
||||||
|
nil,
|
||||||
|
OpenAIUpstreamTransportHTTPSSE,
|
||||||
|
OpenAIEndpointCapabilityEmbeddings,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, selection)
|
||||||
|
require.NotNil(t, selection.Account)
|
||||||
|
require.Equal(t, int64(37012), selection.Account.ID)
|
||||||
|
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||||
|
require.Equal(t, 1, decision.CandidateCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIGatewayService_SelectAccountWithScheduler_Enabled_EmbeddingsSkipsChatOnlyStickyBindings(t *testing.T) {
|
||||||
|
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
groupID := int64(10112)
|
||||||
|
accounts := []Account{
|
||||||
|
{
|
||||||
|
ID: 37021,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
Concurrency: 1,
|
||||||
|
Priority: 0,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions"},
|
||||||
|
},
|
||||||
|
Extra: map[string]any{
|
||||||
|
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 37022,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
Concurrency: 1,
|
||||||
|
Priority: 5,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions", "embeddings"},
|
||||||
|
},
|
||||||
|
Extra: map[string]any{
|
||||||
|
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := newSchedulerTestOpenAIWSV2Config()
|
||||||
|
cfg.Gateway.Scheduling.LoadBatchEnabled = false
|
||||||
|
cache := &schedulerTestGatewayCache{
|
||||||
|
sessionBindings: map[string]int64{
|
||||||
|
"openai:session_hash_embeddings": 37021,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := &OpenAIGatewayService{
|
||||||
|
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
|
||||||
|
cache: cache,
|
||||||
|
cfg: cfg,
|
||||||
|
rateLimitService: newOpenAIAdvancedSchedulerRateLimitService("true"),
|
||||||
|
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
|
||||||
|
}
|
||||||
|
store := svc.getOpenAIWSStateStore()
|
||||||
|
require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_embeddings_chat_only", 37021, time.Hour))
|
||||||
|
|
||||||
|
selection, decision, err := svc.SelectAccountWithSchedulerForCapability(
|
||||||
|
ctx,
|
||||||
|
&groupID,
|
||||||
|
"resp_embeddings_chat_only",
|
||||||
|
"session_hash_embeddings",
|
||||||
|
"text-embedding-3-small",
|
||||||
|
nil,
|
||||||
|
OpenAIUpstreamTransportHTTPSSE,
|
||||||
|
OpenAIEndpointCapabilityEmbeddings,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, selection)
|
||||||
|
require.NotNil(t, selection.Account)
|
||||||
|
require.Equal(t, int64(37022), selection.Account.ID)
|
||||||
|
require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer)
|
||||||
|
require.False(t, decision.StickyPreviousHit)
|
||||||
|
require.False(t, decision.StickySessionHit)
|
||||||
|
require.Equal(t, int64(37022), cache.sessionBindings["openai:session_hash_embeddings"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *testing.T) {
|
func TestOpenAIGatewayService_OpenAIAccountSchedulerMetrics_DisabledNoOp(t *testing.T) {
|
||||||
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
resetOpenAIAdvancedSchedulerSettingCacheForTest()
|
||||||
|
|
||||||
|
|||||||
@ -1279,7 +1279,7 @@ func (s *OpenAIGatewayService) SelectAccountForModel(ctx context.Context, groupI
|
|||||||
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
// SelectAccountForModelWithExclusions selects an account supporting the requested model while excluding specified accounts.
|
||||||
// SelectAccountForModelWithExclusions 选择支持指定模型的账号,同时排除指定的账号。
|
// SelectAccountForModelWithExclusions 选择支持指定模型的账号,同时排除指定的账号。
|
||||||
func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*Account, error) {
|
||||||
return s.selectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs, false, 0)
|
return s.selectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs, false, 0, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// noAvailableOpenAISelectionError builds the standard "no account available" error
|
// noAvailableOpenAISelectionError builds the standard "no account available" error
|
||||||
@ -1312,13 +1312,16 @@ func openAICompactSupportTier(account *Account) int {
|
|||||||
|
|
||||||
// isOpenAIAccountEligibleForRequest centralises the schedulable / OpenAI / model /
|
// isOpenAIAccountEligibleForRequest centralises the schedulable / OpenAI / model /
|
||||||
// compact-support checks used during account selection.
|
// compact-support checks used during account selection.
|
||||||
func isOpenAIAccountEligibleForRequest(ctx context.Context, account *Account, requestedModel string, requireCompact bool) bool {
|
func isOpenAIAccountEligibleForRequest(ctx context.Context, account *Account, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) bool {
|
||||||
if account == nil || !account.IsOpenAI() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) {
|
if account == nil || !account.IsOpenAI() || !account.IsSchedulableForModelWithContext(ctx, requestedModel) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
|
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !account.SupportsOpenAIEndpointCapability(requiredCapability) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if requireCompact && openAICompactSupportTier(account) == 0 {
|
if requireCompact && openAICompactSupportTier(account) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@ -1366,7 +1369,7 @@ func resolveOpenAIAccountUpstreamModelForRequest(account *Account, requestedMode
|
|||||||
return upstreamModel
|
return upstreamModel
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64) (*Account, error) {
|
func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability) (*Account, error) {
|
||||||
if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
|
if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
|
||||||
slog.Warn("channel pricing restriction blocked request",
|
slog.Warn("channel pricing restriction blocked request",
|
||||||
"group_id", derefGroupID(groupID),
|
"group_id", derefGroupID(groupID),
|
||||||
@ -1376,7 +1379,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C
|
|||||||
|
|
||||||
// 1. 尝试粘性会话命中
|
// 1. 尝试粘性会话命中
|
||||||
// Try sticky session hit
|
// Try sticky session hit
|
||||||
if account := s.tryStickySessionHit(ctx, groupID, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID); account != nil {
|
if account := s.tryStickySessionHit(ctx, groupID, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability); account != nil {
|
||||||
return account, nil
|
return account, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1389,7 +1392,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C
|
|||||||
|
|
||||||
// 3. 按优先级 + LRU 选择最佳账号
|
// 3. 按优先级 + LRU 选择最佳账号
|
||||||
// Select by priority + LRU
|
// Select by priority + LRU
|
||||||
selected, compactBlocked := s.selectBestAccount(ctx, groupID, accounts, requestedModel, excludedIDs, requireCompact)
|
selected, compactBlocked := s.selectBestAccount(ctx, groupID, accounts, requestedModel, excludedIDs, requireCompact, requiredCapability)
|
||||||
|
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
return nil, noAvailableOpenAISelectionError(requestedModel, compactBlocked)
|
return nil, noAvailableOpenAISelectionError(requestedModel, compactBlocked)
|
||||||
@ -1414,7 +1417,7 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusions(ctx context.C
|
|||||||
//
|
//
|
||||||
// tryStickySessionHit attempts to get account from sticky session.
|
// tryStickySessionHit attempts to get account from sticky session.
|
||||||
// Returns account if hit and usable; clears session and returns nil if account is unavailable.
|
// Returns account if hit and usable; clears session and returns nil if account is unavailable.
|
||||||
func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID *int64, sessionHash, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64) *Account {
|
func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID *int64, sessionHash, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, stickyAccountID int64, requiredCapability OpenAIEndpointCapability) *Account {
|
||||||
if sessionHash == "" {
|
if sessionHash == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -1446,14 +1449,14 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID
|
|||||||
|
|
||||||
// 验证账号是否可用于当前请求
|
// 验证账号是否可用于当前请求
|
||||||
// Verify account is usable for current request
|
// Verify account is usable for current request
|
||||||
if !isOpenAIAccountEligibleForRequest(ctx, account, requestedModel, false) {
|
if !isOpenAIAccountEligibleForRequest(ctx, account, requestedModel, false, requiredCapability) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if s.isOpenAIAccountRuntimeBlocked(account) {
|
if s.isOpenAIAccountRuntimeBlocked(account) {
|
||||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact)
|
account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact, requiredCapability)
|
||||||
if account == nil {
|
if account == nil {
|
||||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||||
return nil
|
return nil
|
||||||
@ -1477,7 +1480,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID
|
|||||||
// Returns nil if no available account. The second return reports whether at
|
// Returns nil if no available account. The second return reports whether at
|
||||||
// least one candidate was filtered out solely because it lacks compact support
|
// least one candidate was filtered out solely because it lacks compact support
|
||||||
// (only meaningful when requireCompact=true).
|
// (only meaningful when requireCompact=true).
|
||||||
func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *int64, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool) (*Account, bool) {
|
func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *int64, accounts []Account, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability) (*Account, bool) {
|
||||||
var selected *Account
|
var selected *Account
|
||||||
selectedCompactTier := -1
|
selectedCompactTier := -1
|
||||||
compactBlocked := false
|
compactBlocked := false
|
||||||
@ -1492,11 +1495,11 @@ func (s *OpenAIGatewayService) selectBestAccount(ctx context.Context, groupID *i
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel, false)
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel, false, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, false)
|
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, false, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1573,10 +1576,10 @@ func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool
|
|||||||
|
|
||||||
// SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan.
|
// SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan.
|
||||||
func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) {
|
func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) {
|
||||||
return s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, excludedIDs, false)
|
return s.selectAccountWithLoadAwareness(ctx, groupID, sessionHash, requestedModel, excludedIDs, false, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool) (*AccountSelectionResult, error) {
|
func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}, requireCompact bool, requiredCapability OpenAIEndpointCapability) (*AccountSelectionResult, error) {
|
||||||
if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
|
if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) {
|
||||||
slog.Warn("channel pricing restriction blocked request",
|
slog.Warn("channel pricing restriction blocked request",
|
||||||
"group_id", derefGroupID(groupID),
|
"group_id", derefGroupID(groupID),
|
||||||
@ -1593,7 +1596,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.concurrencyService == nil || !cfg.LoadBatchEnabled {
|
if s.concurrencyService == nil || !cfg.LoadBatchEnabled {
|
||||||
account, err := s.selectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID)
|
account, err := s.selectAccountForModelWithExclusions(ctx, groupID, sessionHash, requestedModel, excludedIDs, requireCompact, stickyAccountID, requiredCapability)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -1646,8 +1649,8 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
if clearSticky {
|
if clearSticky {
|
||||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||||
}
|
}
|
||||||
if !clearSticky && isOpenAIAccountEligibleForRequest(ctx, account, requestedModel, false) {
|
if !clearSticky && isOpenAIAccountEligibleForRequest(ctx, account, requestedModel, false, requiredCapability) {
|
||||||
account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact)
|
account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact, requiredCapability)
|
||||||
if account == nil {
|
if account == nil {
|
||||||
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
_ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash)
|
||||||
} else if s.isOpenAIAccountRuntimeBlocked(account) {
|
} else if s.isOpenAIAccountRuntimeBlocked(account) {
|
||||||
@ -1691,15 +1694,12 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
// Scheduler snapshots can be temporarily stale (bucket rebuild is throttled);
|
// Scheduler snapshots can be temporarily stale (bucket rebuild is throttled);
|
||||||
// re-check schedulability here so recently rate-limited/overloaded accounts
|
// re-check schedulability here so recently rate-limited/overloaded accounts
|
||||||
// are not selected again before the bucket is rebuilt.
|
// are not selected again before the bucket is rebuilt.
|
||||||
if !acc.IsSchedulable() {
|
if !isOpenAIAccountEligibleForRequest(ctx, acc, requestedModel, false, requiredCapability) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.isOpenAIAccountRuntimeBlocked(acc) {
|
if s.isOpenAIAccountRuntimeBlocked(acc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if requestedModel != "" && !acc.IsModelSupported(requestedModel) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) {
|
if needsUpstreamCheck && s.isUpstreamModelRestrictedByChannel(ctx, *groupID, acc, requestedModel, requireCompact) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1779,11 +1779,11 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range selectionOrder {
|
for _, item := range selectionOrder {
|
||||||
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, item.account, requestedModel, false)
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, item.account, requestedModel, false, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact)
|
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1813,11 +1813,11 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
ordered = prioritizeOpenAICompactAccounts(ordered)
|
ordered = prioritizeOpenAICompactAccounts(ordered)
|
||||||
}
|
}
|
||||||
for _, acc := range ordered {
|
for _, acc := range ordered {
|
||||||
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel, false)
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel, false, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact)
|
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1858,11 +1858,11 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex
|
|||||||
candidates = prioritizeOpenAICompactAccounts(candidates)
|
candidates = prioritizeOpenAICompactAccounts(candidates)
|
||||||
}
|
}
|
||||||
for _, acc := range candidates {
|
for _, acc := range candidates {
|
||||||
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel, false)
|
fresh := s.resolveFreshSchedulableOpenAIAccount(ctx, acc, requestedModel, false, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact)
|
fresh = s.recheckSelectedOpenAIAccountFromDB(ctx, fresh, requestedModel, requireCompact, requiredCapability)
|
||||||
if fresh == nil {
|
if fresh == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1910,7 +1910,7 @@ func (s *OpenAIGatewayService) tryAcquireAccountSlot(ctx context.Context, accoun
|
|||||||
return s.concurrencyService.AcquireAccountSlot(ctx, accountID, maxConcurrency)
|
return s.concurrencyService.AcquireAccountSlot(ctx, accountID, maxConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.Context, account *Account, requestedModel string, requireCompact bool) *Account {
|
func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.Context, account *Account, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account {
|
||||||
if account == nil {
|
if account == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -1924,7 +1924,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.
|
|||||||
fresh = current
|
fresh = current
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isOpenAIAccountEligibleForRequest(ctx, fresh, requestedModel, requireCompact) {
|
if !isOpenAIAccountEligibleForRequest(ctx, fresh, requestedModel, requireCompact, requiredCapability) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if s.isOpenAIAccountRuntimeBlocked(fresh) {
|
if s.isOpenAIAccountRuntimeBlocked(fresh) {
|
||||||
@ -1933,12 +1933,12 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccount(ctx context.
|
|||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Context, account *Account, requestedModel string, requireCompact bool) *Account {
|
func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Context, account *Account, requestedModel string, requireCompact bool, requiredCapability OpenAIEndpointCapability) *Account {
|
||||||
if account == nil {
|
if account == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if s.schedulerSnapshot == nil || s.accountRepo == nil {
|
if s.schedulerSnapshot == nil || s.accountRepo == nil {
|
||||||
if !isOpenAIAccountEligibleForRequest(ctx, account, requestedModel, requireCompact) {
|
if !isOpenAIAccountEligibleForRequest(ctx, account, requestedModel, requireCompact, requiredCapability) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return account
|
return account
|
||||||
@ -1948,7 +1948,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDB(ctx context.Co
|
|||||||
if err != nil || latest == nil {
|
if err != nil || latest == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !isOpenAIAccountEligibleForRequest(ctx, latest, requestedModel, requireCompact) {
|
if !isOpenAIAccountEligibleForRequest(ctx, latest, requestedModel, requireCompact, requiredCapability) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if s.isOpenAIAccountRuntimeBlocked(latest) {
|
if s.isOpenAIAccountRuntimeBlocked(latest) {
|
||||||
|
|||||||
@ -413,6 +413,79 @@ func TestAccountSupportsOpenAIImageCapability_OAuthSupportsNative(t *testing.T)
|
|||||||
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative))
|
require.True(t, account.SupportsOpenAIImageCapability(OpenAIImagesCapabilityNative))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
|
||||||
|
t.Run("OpenAI APIKey 默认兼容 chat 和 embeddings", func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||||
|
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OpenAI OAuth 默认仅兼容 chat", func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||||
|
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("显式列表支持同时声明 chat 和 embeddings", func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions", "embeddings"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||||
|
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("显式列表只声明 chat 时不支持 embeddings", func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||||
|
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("显式 map 支持单独关闭 chat 并开启 embeddings", func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": map[string]any{
|
||||||
|
"chat_completions": false,
|
||||||
|
"embeddings": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
|
||||||
|
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("未知能力不应默认放行", func(t *testing.T) {
|
||||||
|
account := &Account{
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapability("unknown")))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildOpenAIImagesURL_HandlesVersionedBaseURL(t *testing.T) {
|
func TestBuildOpenAIImagesURL_HandlesVersionedBaseURL(t *testing.T) {
|
||||||
require.Equal(t,
|
require.Equal(t,
|
||||||
"https://image-upstream.example/v1/images/generations",
|
"https://image-upstream.example/v1/images/generations",
|
||||||
|
|||||||
@ -268,6 +268,52 @@ func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_BusyKeepsSticky(
|
|||||||
require.Equal(t, int64(21), selection.WaitPlan.AccountID)
|
require.Equal(t, int64(21), selection.WaitPlan.AccountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenAIGatewayService_SelectAccountByPreviousResponseID_CapabilityMismatchKeepsSticky(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
groupID := int64(25)
|
||||||
|
account := Account{
|
||||||
|
ID: 31,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Status: StatusActive,
|
||||||
|
Schedulable: true,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"openai_capabilities": []any{"chat_completions"},
|
||||||
|
},
|
||||||
|
Extra: map[string]any{
|
||||||
|
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cache := &stubGatewayCache{}
|
||||||
|
store := NewOpenAIWSStateStore(cache)
|
||||||
|
cfg := newOpenAIWSV2TestConfig()
|
||||||
|
svc := &OpenAIGatewayService{
|
||||||
|
accountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
|
||||||
|
cache: cache,
|
||||||
|
cfg: cfg,
|
||||||
|
concurrencyService: NewConcurrencyService(stubConcurrencyCache{}),
|
||||||
|
openaiWSStateStore: store,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, store.BindResponseAccount(ctx, groupID, "resp_prev_capability", account.ID, time.Hour))
|
||||||
|
|
||||||
|
selection, err := svc.selectAccountByPreviousResponseIDForCapability(
|
||||||
|
ctx,
|
||||||
|
&groupID,
|
||||||
|
"resp_prev_capability",
|
||||||
|
"text-embedding-3-small",
|
||||||
|
nil,
|
||||||
|
OpenAIEndpointCapabilityEmbeddings,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, selection)
|
||||||
|
boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_prev_capability")
|
||||||
|
require.NoError(t, getErr)
|
||||||
|
require.Equal(t, account.ID, boundAccountID)
|
||||||
|
}
|
||||||
|
|
||||||
func newOpenAIWSV2TestConfig() *config.Config {
|
func newOpenAIWSV2TestConfig() *config.Config {
|
||||||
cfg := &config.Config{}
|
cfg := &config.Config{}
|
||||||
cfg.Gateway.OpenAIWS.Enabled = true
|
cfg.Gateway.OpenAIWS.Enabled = true
|
||||||
|
|||||||
@ -3987,6 +3987,18 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID(
|
|||||||
requestedModel string,
|
requestedModel string,
|
||||||
excludedIDs map[int64]struct{},
|
excludedIDs map[int64]struct{},
|
||||||
requireCompact bool,
|
requireCompact bool,
|
||||||
|
) (*AccountSelectionResult, error) {
|
||||||
|
return s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, "", requireCompact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OpenAIGatewayService) selectAccountByPreviousResponseIDForCapability(
|
||||||
|
ctx context.Context,
|
||||||
|
groupID *int64,
|
||||||
|
previousResponseID string,
|
||||||
|
requestedModel string,
|
||||||
|
excludedIDs map[int64]struct{},
|
||||||
|
requiredCapability OpenAIEndpointCapability,
|
||||||
|
requireCompact bool,
|
||||||
) (*AccountSelectionResult, error) {
|
) (*AccountSelectionResult, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -4027,12 +4039,31 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID(
|
|||||||
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
|
if requestedModel != "" && !account.IsModelSupported(requestedModel) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, requestedModel, requireCompact)
|
if !account.SupportsOpenAIEndpointCapability(requiredCapability) {
|
||||||
if account == nil {
|
|
||||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// 兜底:若上游 compact 能力刚被探测为不支持,但 sticky 还在,需要主动放弃。
|
if s.schedulerSnapshot != nil && s.accountRepo != nil {
|
||||||
|
latest, latestErr := s.accountRepo.GetByID(ctx, account.ID)
|
||||||
|
if latestErr != nil || latest == nil {
|
||||||
|
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if shouldClearStickySession(latest, requestedModel) || !latest.IsOpenAI() || !latest.IsSchedulable() {
|
||||||
|
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if requestedModel != "" && !latest.IsModelSupported(requestedModel) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !latest.SupportsOpenAIEndpointCapability(requiredCapability) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if s.isOpenAIAccountRuntimeBlocked(latest) {
|
||||||
|
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
account = latest
|
||||||
|
}
|
||||||
if requireCompact && openAICompactSupportTier(account) == 0 {
|
if requireCompact && openAICompactSupportTier(account) == 0 {
|
||||||
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
_ = store.DeleteResponseAccount(ctx, derefGroupID(groupID), responseID)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@ -2679,7 +2679,7 @@
|
|||||||
<!-- OpenAI APIKey Responses API support mode -->
|
<!-- OpenAI APIKey Responses API support mode -->
|
||||||
<div
|
<div
|
||||||
v-if="form.platform === 'openai' && accountCategory === 'apikey'"
|
v-if="form.platform === 'openai' && accountCategory === 'apikey'"
|
||||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -2696,6 +2696,26 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-2 block">{{ t('admin.accounts.openai.endpointCapabilities') }}</label>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="option in openAIEndpointCapabilityOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-dark-600"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
|
||||||
|
:data-testid="`openai-endpoint-capability-${option.value}`"
|
||||||
|
:checked="openAIEndpointCapabilities.includes(option.value)"
|
||||||
|
@change="toggleOpenAIEndpointCapability(option.value, $event)"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-700 dark:text-gray-200">{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.openai.endpointCapabilitiesDesc') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@ -3172,7 +3192,8 @@ import type {
|
|||||||
CreateAccountRequest,
|
CreateAccountRequest,
|
||||||
CodexSessionImportMessage,
|
CodexSessionImportMessage,
|
||||||
OpenAICompactMode,
|
OpenAICompactMode,
|
||||||
OpenAIResponsesMode
|
OpenAIResponsesMode,
|
||||||
|
OpenAIEndpointCapability
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
@ -3350,6 +3371,7 @@ const autoPauseOnExpired = ref(true)
|
|||||||
const openaiPassthroughEnabled = ref(false)
|
const openaiPassthroughEnabled = ref(false)
|
||||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||||
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
|
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
|
||||||
|
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
|
||||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
const codexCLIOnlyEnabled = ref(false)
|
const codexCLIOnlyEnabled = ref(false)
|
||||||
@ -3412,6 +3434,43 @@ const openAIResponsesModeOptions = computed(() => [
|
|||||||
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
|
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
|
||||||
{ value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') }
|
{ value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') }
|
||||||
])
|
])
|
||||||
|
const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [
|
||||||
|
{ value: 'chat_completions', label: t('admin.accounts.openai.capabilityChatCompletions') },
|
||||||
|
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
|
||||||
|
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
|
||||||
|
const selected = allowed.filter((value) => values.includes(value))
|
||||||
|
return selected.length > 0 ? selected : allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, event?: Event) => {
|
||||||
|
if (openAIEndpointCapabilities.value.includes(capability)) {
|
||||||
|
if (openAIEndpointCapabilities.value.length <= 1) {
|
||||||
|
const input = event?.target as HTMLInputElement | null
|
||||||
|
if (input) input.checked = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openAIEndpointCapabilities.value = openAIEndpointCapabilities.value.filter(
|
||||||
|
(value) => value !== capability
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openAIEndpointCapabilities.value = normalizeOpenAIEndpointCapabilities([
|
||||||
|
...openAIEndpointCapabilities.value,
|
||||||
|
capability
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyOpenAIEndpointCapabilities = (credentials: Record<string, unknown>) => {
|
||||||
|
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
|
||||||
|
if (capabilities.length === 2) {
|
||||||
|
delete credentials.openai_capabilities
|
||||||
|
return
|
||||||
|
}
|
||||||
|
credentials.openai_capabilities = capabilities
|
||||||
|
}
|
||||||
|
|
||||||
function buildAntigravityExtra(): Record<string, unknown> | undefined {
|
function buildAntigravityExtra(): Record<string, unknown> | undefined {
|
||||||
const extra: Record<string, unknown> = {}
|
const extra: Record<string, unknown> = {}
|
||||||
@ -3721,6 +3780,7 @@ watch(
|
|||||||
}
|
}
|
||||||
if (newPlatform !== 'openai') {
|
if (newPlatform !== 'openai') {
|
||||||
openaiPassthroughEnabled.value = false
|
openaiPassthroughEnabled.value = false
|
||||||
|
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
codexCLIOnlyEnabled.value = false
|
codexCLIOnlyEnabled.value = false
|
||||||
@ -4120,6 +4180,7 @@ const resetForm = () => {
|
|||||||
openaiPassthroughEnabled.value = false
|
openaiPassthroughEnabled.value = false
|
||||||
openAICompactMode.value = 'auto'
|
openAICompactMode.value = 'auto'
|
||||||
openAIResponsesMode.value = 'auto'
|
openAIResponsesMode.value = 'auto'
|
||||||
|
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
codexCLIOnlyEnabled.value = false
|
codexCLIOnlyEnabled.value = false
|
||||||
@ -4498,6 +4559,7 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (form.platform === 'openai') {
|
if (form.platform === 'openai') {
|
||||||
|
applyOpenAIEndpointCapabilities(credentials)
|
||||||
const compactModelMapping = buildOpenAICompactModelMapping()
|
const compactModelMapping = buildOpenAICompactModelMapping()
|
||||||
if (compactModelMapping) {
|
if (compactModelMapping) {
|
||||||
credentials.compact_model_mapping = compactModelMapping
|
credentials.compact_model_mapping = compactModelMapping
|
||||||
@ -4620,6 +4682,9 @@ const createAccountAndFinish = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (platform === 'openai') {
|
if (platform === 'openai') {
|
||||||
|
if (type === 'apikey') {
|
||||||
|
applyOpenAIEndpointCapabilities(credentials)
|
||||||
|
}
|
||||||
const compactModelMapping = buildOpenAICompactModelMapping()
|
const compactModelMapping = buildOpenAICompactModelMapping()
|
||||||
if (compactModelMapping) {
|
if (compactModelMapping) {
|
||||||
credentials.compact_model_mapping = compactModelMapping
|
credentials.compact_model_mapping = compactModelMapping
|
||||||
|
|||||||
@ -1439,7 +1439,7 @@
|
|||||||
<!-- OpenAI APIKey Responses API support mode -->
|
<!-- OpenAI APIKey Responses API support mode -->
|
||||||
<div
|
<div
|
||||||
v-if="account?.platform === 'openai' && account?.type === 'apikey'"
|
v-if="account?.platform === 'openai' && account?.type === 'apikey'"
|
||||||
class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-3"
|
class="space-y-4 border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -1459,6 +1459,26 @@
|
|||||||
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
||||||
<span class="font-medium">{{ t(openAIResponsesStatusKey) }}</span>
|
<span class="font-medium">{{ t(openAIResponsesStatusKey) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="input-label mb-2 block">{{ t('admin.accounts.openai.endpointCapabilities') }}</label>
|
||||||
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<label
|
||||||
|
v-for="option in openAIEndpointCapabilityOptions"
|
||||||
|
:key="option.value"
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-dark-600"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500 dark:border-dark-500"
|
||||||
|
:data-testid="`openai-endpoint-capability-${option.value}`"
|
||||||
|
:checked="openAIEndpointCapabilities.includes(option.value)"
|
||||||
|
@change="toggleOpenAIEndpointCapability(option.value, $event)"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-700 dark:text-gray-200">{{ option.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint">{{ t('admin.accounts.openai.endpointCapabilitiesDesc') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Anthropic API Key 自动透传开关 -->
|
<!-- Anthropic API Key 自动透传开关 -->
|
||||||
@ -2245,7 +2265,15 @@ import { useAppStore } from '@/stores/app'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
|
import { useQuotaNotifyState } from '@/composables/useQuotaNotifyState'
|
||||||
import type { Account, Proxy, AdminGroup, CheckMixedChannelResponse, OpenAICompactMode, OpenAIResponsesMode } from '@/types'
|
import type {
|
||||||
|
Account,
|
||||||
|
Proxy,
|
||||||
|
AdminGroup,
|
||||||
|
CheckMixedChannelResponse,
|
||||||
|
OpenAICompactMode,
|
||||||
|
OpenAIResponsesMode,
|
||||||
|
OpenAIEndpointCapability
|
||||||
|
} from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
@ -2433,6 +2461,7 @@ const customBaseUrl = ref('')
|
|||||||
const openaiPassthroughEnabled = ref(false)
|
const openaiPassthroughEnabled = ref(false)
|
||||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||||
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
|
const openAIResponsesMode = ref<OpenAIResponsesMode>('auto')
|
||||||
|
const openAIEndpointCapabilities = ref<OpenAIEndpointCapability[]>(['chat_completions', 'embeddings'])
|
||||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||||
const codexCLIOnlyEnabled = ref(false)
|
const codexCLIOnlyEnabled = ref(false)
|
||||||
@ -2539,6 +2568,63 @@ const openAIResponsesModeOptions = computed(() => [
|
|||||||
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
|
{ value: 'force_responses', label: t('admin.accounts.openai.responsesModeForceResponses') },
|
||||||
{ value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') }
|
{ value: 'force_chat_completions', label: t('admin.accounts.openai.responsesModeForceChatCompletions') }
|
||||||
])
|
])
|
||||||
|
const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [
|
||||||
|
{ value: 'chat_completions', label: t('admin.accounts.openai.capabilityChatCompletions') },
|
||||||
|
{ value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => {
|
||||||
|
const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings']
|
||||||
|
const selected = allowed.filter((value) => values.includes(value))
|
||||||
|
return selected.length > 0 ? selected : allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
const readOpenAIEndpointCapabilities = (credentials?: Record<string, unknown>): OpenAIEndpointCapability[] => {
|
||||||
|
const raw = credentials?.openai_capabilities
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
return normalizeOpenAIEndpointCapabilities(
|
||||||
|
raw.filter((value): value is OpenAIEndpointCapability =>
|
||||||
|
value === 'chat_completions' || value === 'embeddings'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (raw !== null && typeof raw === 'object') {
|
||||||
|
const capabilityMap = raw as Record<string, unknown>
|
||||||
|
return normalizeOpenAIEndpointCapabilities(
|
||||||
|
openAIEndpointCapabilityOptions.value
|
||||||
|
.map((option) => option.value)
|
||||||
|
.filter((value) => capabilityMap[value] === true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ['chat_completions', 'embeddings']
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, event?: Event) => {
|
||||||
|
if (openAIEndpointCapabilities.value.includes(capability)) {
|
||||||
|
if (openAIEndpointCapabilities.value.length <= 1) {
|
||||||
|
const input = event?.target as HTMLInputElement | null
|
||||||
|
if (input) input.checked = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openAIEndpointCapabilities.value = openAIEndpointCapabilities.value.filter(
|
||||||
|
(value) => value !== capability
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openAIEndpointCapabilities.value = normalizeOpenAIEndpointCapabilities([
|
||||||
|
...openAIEndpointCapabilities.value,
|
||||||
|
capability
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyOpenAIEndpointCapabilities = (credentials: Record<string, unknown>) => {
|
||||||
|
const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value)
|
||||||
|
if (capabilities.length === 2) {
|
||||||
|
delete credentials.openai_capabilities
|
||||||
|
return
|
||||||
|
}
|
||||||
|
credentials.openai_capabilities = capabilities
|
||||||
|
}
|
||||||
const normalizeOpenAIResponsesMode = (mode: unknown): OpenAIResponsesMode => {
|
const normalizeOpenAIResponsesMode = (mode: unknown): OpenAIResponsesMode => {
|
||||||
if (mode === 'force_responses' || mode === 'force_chat_completions') {
|
if (mode === 'force_responses' || mode === 'force_chat_completions') {
|
||||||
return mode
|
return mode
|
||||||
@ -2724,6 +2810,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
openaiPassthroughEnabled.value = false
|
openaiPassthroughEnabled.value = false
|
||||||
openAICompactMode.value = 'auto'
|
openAICompactMode.value = 'auto'
|
||||||
openAIResponsesMode.value = 'auto'
|
openAIResponsesMode.value = 'auto'
|
||||||
|
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||||||
openAICompactModelMappings.value = []
|
openAICompactModelMappings.value = []
|
||||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||||
@ -2736,6 +2823,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
|
openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto'
|
||||||
if (newAccount.type === 'apikey') {
|
if (newAccount.type === 'apikey') {
|
||||||
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
|
openAIResponsesMode.value = normalizeOpenAIResponsesMode(extra?.openai_responses_mode)
|
||||||
|
openAIEndpointCapabilities.value = readOpenAIEndpointCapabilities(
|
||||||
|
newAccount.credentials as Record<string, unknown> | undefined
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean'
|
const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean'
|
||||||
? extra.codex_image_generation_bridge
|
? extra.codex_image_generation_bridge
|
||||||
@ -3476,6 +3566,7 @@ const handleSubmit = async () => {
|
|||||||
newCredentials.model_mapping = currentCredentials.model_mapping
|
newCredentials.model_mapping = currentCredentials.model_mapping
|
||||||
}
|
}
|
||||||
if (props.account.platform === 'openai') {
|
if (props.account.platform === 'openai') {
|
||||||
|
applyOpenAIEndpointCapabilities(newCredentials)
|
||||||
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
|
const compactModelMapping = buildModelMappingObject('mapping', [], openAICompactModelMappings.value)
|
||||||
if (compactModelMapping) {
|
if (compactModelMapping) {
|
||||||
newCredentials.compact_model_mapping = compactModelMapping
|
newCredentials.compact_model_mapping = compactModelMapping
|
||||||
|
|||||||
@ -310,6 +310,63 @@ describe('EditAccountModal', () => {
|
|||||||
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(true)
|
expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_responses_supported).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('submits OpenAI APIKey endpoint capabilities from credentials', async () => {
|
||||||
|
const account = buildAccount()
|
||||||
|
account.credentials.openai_capabilities = ['chat_completions']
|
||||||
|
updateAccountMock.mockReset()
|
||||||
|
checkMixedChannelRiskMock.mockReset()
|
||||||
|
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||||
|
updateAccountMock.mockResolvedValue(account)
|
||||||
|
|
||||||
|
const wrapper = mountModal(account)
|
||||||
|
|
||||||
|
expect(wrapper.findAll('input[type="checkbox"]').some((input) => (input.element as HTMLInputElement).checked)).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual([
|
||||||
|
'chat_completions'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps at least one OpenAI APIKey endpoint capability selected', async () => {
|
||||||
|
const account = buildAccount()
|
||||||
|
updateAccountMock.mockReset()
|
||||||
|
checkMixedChannelRiskMock.mockReset()
|
||||||
|
checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false })
|
||||||
|
updateAccountMock.mockResolvedValue(account)
|
||||||
|
|
||||||
|
const wrapper = mountModal(account)
|
||||||
|
|
||||||
|
const chatCheckbox = wrapper.get<HTMLInputElement>(
|
||||||
|
'[data-testid="openai-endpoint-capability-chat_completions"]'
|
||||||
|
)
|
||||||
|
const embeddingsCheckbox = wrapper.get<HTMLInputElement>(
|
||||||
|
'[data-testid="openai-endpoint-capability-embeddings"]'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(chatCheckbox.element.checked).toBe(true)
|
||||||
|
expect(embeddingsCheckbox.element.checked).toBe(true)
|
||||||
|
|
||||||
|
await embeddingsCheckbox.setValue(false)
|
||||||
|
|
||||||
|
expect(chatCheckbox.element.checked).toBe(true)
|
||||||
|
expect(embeddingsCheckbox.element.checked).toBe(false)
|
||||||
|
|
||||||
|
await chatCheckbox.setValue(false)
|
||||||
|
|
||||||
|
expect(chatCheckbox.element.checked).toBe(true)
|
||||||
|
expect(embeddingsCheckbox.element.checked).toBe(false)
|
||||||
|
|
||||||
|
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual([
|
||||||
|
'chat_completions'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('submits account-level Codex image generation bridge override', async () => {
|
it('submits account-level Codex image generation bridge override', async () => {
|
||||||
const account = buildAccount()
|
const account = buildAccount()
|
||||||
account.extra = {
|
account.extra = {
|
||||||
|
|||||||
@ -3353,6 +3353,11 @@ export default {
|
|||||||
responsesModeAuto: 'Auto',
|
responsesModeAuto: 'Auto',
|
||||||
responsesModeForceResponses: 'Force Responses',
|
responsesModeForceResponses: 'Force Responses',
|
||||||
responsesModeForceChatCompletions: 'Force Chat Completions',
|
responsesModeForceChatCompletions: 'Force Chat Completions',
|
||||||
|
endpointCapabilities: 'Endpoint capabilities',
|
||||||
|
endpointCapabilitiesDesc:
|
||||||
|
'Used by account routing. Both endpoints are allowed by default; if the upstream only supports one, select only the supported endpoint.',
|
||||||
|
capabilityChatCompletions: 'Chat Completions',
|
||||||
|
capabilityEmbeddings: 'Embeddings',
|
||||||
responsesStatusAutoSupported: 'Auto probe: Responses',
|
responsesStatusAutoSupported: 'Auto probe: Responses',
|
||||||
responsesStatusAutoUnsupported: 'Auto probe: Chat Completions',
|
responsesStatusAutoUnsupported: 'Auto probe: Chat Completions',
|
||||||
responsesStatusAutoUnknown: 'Auto probe: unknown',
|
responsesStatusAutoUnknown: 'Auto probe: unknown',
|
||||||
|
|||||||
@ -3499,6 +3499,11 @@ export default {
|
|||||||
responsesModeAuto: '自动',
|
responsesModeAuto: '自动',
|
||||||
responsesModeForceResponses: '强制 Responses',
|
responsesModeForceResponses: '强制 Responses',
|
||||||
responsesModeForceChatCompletions: '强制 Chat Completions',
|
responsesModeForceChatCompletions: '强制 Chat Completions',
|
||||||
|
endpointCapabilities: '端点能力',
|
||||||
|
endpointCapabilitiesDesc:
|
||||||
|
'用于调度筛选。默认两个端点都可用;如果上游只支持其中一个,请只勾选实际支持的端点。',
|
||||||
|
capabilityChatCompletions: 'Chat Completions',
|
||||||
|
capabilityEmbeddings: 'Embeddings',
|
||||||
responsesStatusAutoSupported: '自动探测:Responses',
|
responsesStatusAutoSupported: '自动探测:Responses',
|
||||||
responsesStatusAutoUnsupported: '自动探测:Chat Completions',
|
responsesStatusAutoUnsupported: '自动探测:Chat Completions',
|
||||||
responsesStatusAutoUnknown: '自动探测:未探测',
|
responsesStatusAutoUnknown: '自动探测:未探测',
|
||||||
|
|||||||
@ -997,6 +997,7 @@ export interface CodexUsageSnapshot {
|
|||||||
|
|
||||||
export type OpenAICompactMode = 'auto' | 'force_on' | 'force_off'
|
export type OpenAICompactMode = 'auto' | 'force_on' | 'force_off'
|
||||||
export type OpenAIResponsesMode = 'auto' | 'force_responses' | 'force_chat_completions'
|
export type OpenAIResponsesMode = 'auto' | 'force_responses' | 'force_chat_completions'
|
||||||
|
export type OpenAIEndpointCapability = 'chat_completions' | 'embeddings'
|
||||||
|
|
||||||
export interface OpenAICompactState {
|
export interface OpenAICompactState {
|
||||||
openai_compact_mode?: OpenAICompactMode
|
openai_compact_mode?: OpenAICompactMode
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user