openai_bulk_account_settings.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. package service
  2. import (
  3. "fmt"
  4. "strconv"
  5. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  6. "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
  7. )
  8. type bulkOpenAISettings struct {
  9. longContextBilling bool
  10. endpointCapabilities bool
  11. responsesMode bool
  12. capabilitiesIncludeChat bool
  13. forcedResponsesMode bool
  14. }
  15. func (s bulkOpenAISettings) any() bool {
  16. return s.longContextBilling || s.endpointCapabilities || s.responsesMode
  17. }
  18. func normalizeBulkOpenAISettings(input *BulkUpdateAccountsInput) (bulkOpenAISettings, error) {
  19. var settings bulkOpenAISettings
  20. if input == nil {
  21. return settings, nil
  22. }
  23. if _, exists := input.Extra[openAILongContextBillingEnabledKey]; exists {
  24. settings.longContextBilling = true
  25. if err := ValidateOpenAILongContextBillingExtra(PlatformOpenAI, input.Extra); err != nil {
  26. return settings, err
  27. }
  28. }
  29. if raw, exists := input.Credentials[openAIEndpointCapabilitiesCredentialKey]; exists {
  30. settings.endpointCapabilities = true
  31. capabilities, includeChat, err := normalizeBulkOpenAIEndpointCapabilities(raw)
  32. if err != nil {
  33. return settings, err
  34. }
  35. settings.capabilitiesIncludeChat = includeChat
  36. input.Credentials[openAIEndpointCapabilitiesCredentialKey] = capabilities
  37. }
  38. if raw, exists := input.Extra[openai_compat.ExtraKeyResponsesMode]; exists {
  39. settings.responsesMode = true
  40. mode, forced, err := normalizeBulkOpenAIResponsesMode(raw)
  41. if err != nil {
  42. return settings, err
  43. }
  44. settings.forcedResponsesMode = forced
  45. input.Extra[openai_compat.ExtraKeyResponsesMode] = mode
  46. }
  47. if settings.endpointCapabilities && !settings.capabilitiesIncludeChat {
  48. if settings.forcedResponsesMode {
  49. return settings, infraerrors.BadRequest(
  50. "OPENAI_RESPONSES_MODE_INVALID",
  51. "a forced Responses route requires the chat_completions endpoint capability",
  52. )
  53. }
  54. if input.Extra == nil {
  55. input.Extra = make(map[string]any, 1)
  56. }
  57. input.Extra[openai_compat.ExtraKeyResponsesMode] = nil
  58. settings.responsesMode = true
  59. }
  60. return settings, nil
  61. }
  62. func normalizeBulkOpenAIEndpointCapabilities(raw any) (any, bool, error) {
  63. if raw == nil {
  64. return nil, true, nil
  65. }
  66. values := make([]string, 0, 2)
  67. switch typed := raw.(type) {
  68. case []any:
  69. for _, item := range typed {
  70. value, ok := item.(string)
  71. if !ok {
  72. return nil, false, invalidBulkOpenAIEndpointCapabilities()
  73. }
  74. values = append(values, value)
  75. }
  76. case []string:
  77. values = append(values, typed...)
  78. default:
  79. return nil, false, invalidBulkOpenAIEndpointCapabilities()
  80. }
  81. selected := make(map[string]bool, 2)
  82. for _, value := range values {
  83. switch OpenAIEndpointCapability(value) {
  84. case OpenAIEndpointCapabilityChatCompletions, OpenAIEndpointCapabilityEmbeddings:
  85. selected[value] = true
  86. default:
  87. return nil, false, invalidBulkOpenAIEndpointCapabilities()
  88. }
  89. }
  90. if len(selected) == 0 {
  91. return nil, false, invalidBulkOpenAIEndpointCapabilities()
  92. }
  93. includeChat := selected[string(OpenAIEndpointCapabilityChatCompletions)]
  94. if includeChat && selected[string(OpenAIEndpointCapabilityEmbeddings)] {
  95. return nil, true, nil
  96. }
  97. if includeChat {
  98. return []string{string(OpenAIEndpointCapabilityChatCompletions)}, true, nil
  99. }
  100. return []string{string(OpenAIEndpointCapabilityEmbeddings)}, false, nil
  101. }
  102. func invalidBulkOpenAIEndpointCapabilities() error {
  103. return infraerrors.BadRequest(
  104. "OPENAI_ENDPOINT_CAPABILITIES_INVALID",
  105. "openai_capabilities must contain chat_completions, embeddings, or both",
  106. )
  107. }
  108. func normalizeBulkOpenAIResponsesMode(raw any) (any, bool, error) {
  109. if raw == nil {
  110. return nil, false, nil
  111. }
  112. mode, ok := raw.(string)
  113. if !ok {
  114. return nil, false, invalidBulkOpenAIResponsesMode()
  115. }
  116. switch openai_compat.ResponsesSupportMode(mode) {
  117. case openai_compat.ResponsesSupportModeAuto:
  118. return nil, false, nil
  119. case openai_compat.ResponsesSupportModeForceResponses,
  120. openai_compat.ResponsesSupportModeForceChatCompletions:
  121. return mode, true, nil
  122. default:
  123. return nil, false, invalidBulkOpenAIResponsesMode()
  124. }
  125. }
  126. func invalidBulkOpenAIResponsesMode() error {
  127. return infraerrors.BadRequest(
  128. "OPENAI_RESPONSES_MODE_INVALID",
  129. "openai_responses_mode must be auto, force_responses, force_chat_completions, or null",
  130. )
  131. }
  132. func validateBulkOpenAISettingsTargets(
  133. input *BulkUpdateAccountsInput,
  134. settings bulkOpenAISettings,
  135. targetsByID map[int64]*Account,
  136. ) (int, error) {
  137. if input == nil || !settings.any() {
  138. return 0, nil
  139. }
  140. inheritedCount := 0
  141. for _, accountID := range input.AccountIDs {
  142. account, ok := targetsByID[accountID]
  143. if !ok || account == nil {
  144. return 0, invalidBulkOpenAITarget(accountID, "account does not exist")
  145. }
  146. if settings.longContextBilling {
  147. if account.Platform != PlatformOpenAI || !supportsOpenAILongContextBilling(account.Type) {
  148. return 0, invalidBulkOpenAITarget(accountID, "long-context billing requires an OpenAI OAuth, setup-token, or API-key account")
  149. }
  150. if account.IsShadow() {
  151. inheritedCount++
  152. }
  153. }
  154. if settings.endpointCapabilities || settings.responsesMode {
  155. if account.Platform != PlatformOpenAI || account.Type != AccountTypeAPIKey {
  156. return 0, invalidBulkOpenAITarget(accountID, "endpoint capabilities and Responses routing require an OpenAI API-key account")
  157. }
  158. }
  159. if settings.forcedResponsesMode && !settings.capabilitiesIncludeChat &&
  160. !settings.endpointCapabilities &&
  161. !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions) {
  162. return 0, invalidBulkOpenAITarget(accountID, "a forced Responses route requires the chat_completions endpoint capability")
  163. }
  164. }
  165. if settings.longContextBilling && inheritedCount == len(input.AccountIDs) && bulkUpdateOnlyChangesLongContext(input) {
  166. return 0, infraerrors.BadRequest(
  167. "OPENAI_LONG_CONTEXT_PARENT_REQUIRED",
  168. "long-context billing is owned by parent accounts; select at least one parent account",
  169. )
  170. }
  171. return inheritedCount, nil
  172. }
  173. func supportsOpenAILongContextBilling(accountType string) bool {
  174. switch accountType {
  175. case AccountTypeOAuth, AccountTypeSetupToken, AccountTypeAPIKey:
  176. return true
  177. default:
  178. return false
  179. }
  180. }
  181. func invalidBulkOpenAITarget(accountID int64, message string) error {
  182. return infraerrors.BadRequest(
  183. "OPENAI_BULK_TARGET_INVALID",
  184. fmt.Sprintf("account %d: %s", accountID, message),
  185. ).WithMetadata(map[string]string{"account_id": strconv.FormatInt(accountID, 10)})
  186. }
  187. func bulkUpdateOnlyChangesLongContext(input *BulkUpdateAccountsInput) bool {
  188. if input == nil || input.Name != "" || input.ProxyID != nil || input.Concurrency != nil ||
  189. input.Priority != nil || input.RateMultiplier != nil || input.LoadFactor != nil ||
  190. input.Status != "" || input.Schedulable != nil || input.GroupIDs != nil ||
  191. len(input.Credentials) != 0 || input.ProbeEnabled != nil {
  192. return false
  193. }
  194. if len(input.Extra) != 1 {
  195. return false
  196. }
  197. _, ok := input.Extra[openAILongContextBillingEnabledKey]
  198. return ok
  199. }