grok_upstream_url.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. package service
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "net/url"
  7. "strings"
  8. "github.com/Wei-Shaw/sub2api/internal/config"
  9. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  10. "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
  11. )
  12. func grokBaseURLValidator(account *Account, cfg *config.Config) (xai.BaseURLValidator, error) {
  13. if account == nil || !account.IsGrok() {
  14. return nil, fmt.Errorf("grok account is required")
  15. }
  16. switch account.Type {
  17. case AccountTypeOAuth:
  18. // Official gateway hosts are always trusted and always usable, even when
  19. // the operator enables a restrictive URL allowlist. A custom forwarding
  20. // host is vetted by the same operator policy as API-key accounts.
  21. //
  22. // The official-vs-custom decision is made on the host, not via
  23. // ValidateTrustedBaseURL: that validator relaxes to accept-any under the
  24. // XAI_ALLOW_UNSAFE_URL_OVERRIDES debug switch, which must never let an
  25. // OAuth bearer token reach an arbitrary custom host.
  26. policyValidator := grokOperatorPolicyValidator(cfg)
  27. return redactedGrokBaseURLValidator(func(raw string) (string, error) {
  28. if xai.IsOfficialBaseURL(raw) {
  29. return xai.ValidateTrustedBaseURL(raw)
  30. }
  31. return policyValidator(raw)
  32. }), nil
  33. case AccountTypeAPIKey:
  34. return redactedGrokBaseURLValidator(grokOperatorPolicyValidator(cfg)), nil
  35. default:
  36. return nil, fmt.Errorf("unsupported grok account type: %s", account.Type)
  37. }
  38. }
  39. // grokOperatorPolicyValidator 按全局出站 URL 安全策略校验自定义 base_url:
  40. // 白名单开启时强制 UpstreamHosts;关闭时仅做格式校验(HTTP 允许与否跟随配置)。
  41. func grokOperatorPolicyValidator(cfg *config.Config) xai.BaseURLValidator {
  42. if cfg == nil {
  43. return xai.ValidateBaseURL
  44. }
  45. if !cfg.Security.URLAllowlist.Enabled {
  46. return func(raw string) (string, error) {
  47. return urlvalidator.ValidateURLFormat(raw, cfg.Security.URLAllowlist.AllowInsecureHTTP)
  48. }
  49. }
  50. return func(raw string) (string, error) {
  51. return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
  52. AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts,
  53. RequireAllowlist: true,
  54. AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts,
  55. })
  56. }
  57. }
  58. func redactedGrokBaseURLValidator(validator xai.BaseURLValidator) xai.BaseURLValidator {
  59. return func(raw string) (string, error) {
  60. validated, err := validator(raw)
  61. if err != nil {
  62. return "", errors.New("base URL rejected by URL security policy")
  63. }
  64. return validated, nil
  65. }
  66. }
  67. func buildGrokResponsesURL(account *Account, cfg *config.Config, settings ...*SettingService) (string, error) {
  68. validator, err := grokBaseURLValidator(account, cfg)
  69. if err != nil {
  70. return "", err
  71. }
  72. baseURL := account.GetGrokBaseURL()
  73. if len(settings) > 0 && settings[0] != nil {
  74. baseURL = settings[0].ResolveGrokBaseURL(context.Background(), account)
  75. }
  76. return xai.BuildResponsesURLWithValidator(baseURL, validator)
  77. }
  78. func buildGrokChatCompletionsURL(account *Account, cfg *config.Config, settings ...*SettingService) (string, error) {
  79. validator, err := grokBaseURLValidator(account, cfg)
  80. if err != nil {
  81. return "", err
  82. }
  83. baseURL := account.GetGrokBaseURL()
  84. if len(settings) > 0 && settings[0] != nil {
  85. baseURL = settings[0].ResolveGrokBaseURL(context.Background(), account)
  86. }
  87. return xai.BuildChatCompletionsURLWithValidator(baseURL, validator)
  88. }
  89. // buildGrokBillingURL 解析 billing 探测端点:跟随账号的转发 base_url,
  90. // 未定制的账号仍指向官方 CLI 网关。
  91. func buildGrokBillingURL(account *Account, cfg *config.Config, weekly bool) (string, error) {
  92. validator, err := grokBaseURLValidator(account, cfg)
  93. if err != nil {
  94. return "", err
  95. }
  96. baseURL := account.GetGrokBaseURL()
  97. // Official public/regional API hosts do not expose Grok Build billing.
  98. // Keep custom relays on their configured host because they may proxy the CLI
  99. // billing path alongside inference.
  100. if xai.IsOfficialBaseURL(baseURL) && !isGrokCLIProxyBaseURL(baseURL) {
  101. baseURL = xai.DefaultCLIBaseURL
  102. }
  103. return xai.BuildBillingURLWithValidator(baseURL, weekly, validator)
  104. }
  105. func buildGrokMediaURL(account *Account, cfg *config.Config, endpoint GrokMediaEndpoint, requestID string) (string, error) {
  106. validator, err := grokBaseURLValidator(account, cfg)
  107. if err != nil {
  108. return "", err
  109. }
  110. baseURL := account.GetGrokMediaBaseURL()
  111. switch endpoint {
  112. case GrokMediaEndpointImagesGenerations:
  113. return xai.BuildImagesGenerationsURLWithValidator(baseURL, validator)
  114. case GrokMediaEndpointImagesEdits:
  115. return xai.BuildImagesEditsURLWithValidator(baseURL, validator)
  116. case GrokMediaEndpointVideosGenerations:
  117. return xai.BuildVideosGenerationsURLWithValidator(baseURL, validator)
  118. case GrokMediaEndpointVideosEdits:
  119. return xai.BuildVideosEditsURLWithValidator(baseURL, validator)
  120. case GrokMediaEndpointVideosExtensions:
  121. return xai.BuildVideosExtensionsURLWithValidator(baseURL, validator)
  122. case GrokMediaEndpointVideoStatus:
  123. return xai.BuildVideoURLWithValidator(baseURL, requestID, validator)
  124. case GrokMediaEndpointVideoContent:
  125. videoURL, err := xai.BuildVideoURLWithValidator(baseURL, requestID, validator)
  126. if err != nil {
  127. return "", err
  128. }
  129. return videoURL + "/content", nil
  130. default:
  131. return "", fmt.Errorf("unsupported grok media endpoint: %s", endpoint)
  132. }
  133. }
  134. // buildGrokVoiceURL returns the official xAI Voice API endpoint.
  135. // Voice HTTP (/tts, /stt, /custom-voices) and WS (/realtime) are only exposed
  136. // by api.x.ai — the CLI chat proxy does not implement them. When the account
  137. // base_url points at the CLI proxy (or is empty), fall back to DefaultBaseURL.
  138. func buildGrokVoiceURL(account *Account, cfg *config.Config, endpoint string) (string, error) {
  139. validator, err := grokBaseURLValidator(account, cfg)
  140. if err != nil {
  141. return "", err
  142. }
  143. base := ""
  144. if account != nil {
  145. base = account.GetGrokMediaBaseURL()
  146. }
  147. if strings.TrimSpace(base) == "" || isGrokCLIProxyBaseURL(base) {
  148. base = xai.DefaultBaseURL
  149. }
  150. validated, err := validator(base)
  151. if err != nil {
  152. return "", err
  153. }
  154. ep := strings.Trim(strings.TrimSpace(endpoint), "/")
  155. if ep == "" {
  156. return "", fmt.Errorf("voice endpoint is required")
  157. }
  158. parts := strings.Split(ep, "/")
  159. encoded := make([]string, 0, len(parts))
  160. for _, part := range parts {
  161. if strings.TrimSpace(part) == "" || part == "." || part == ".." {
  162. return "", fmt.Errorf("invalid voice endpoint path")
  163. }
  164. encoded = append(encoded, url.PathEscape(part))
  165. }
  166. return strings.TrimRight(validated, "/") + "/" + strings.Join(encoded, "/"), nil
  167. }
  168. func isGrokCLIProxyBaseURL(raw string) bool {
  169. return isGrokCLIProxyTarget(raw)
  170. }