channel_monitor_validate.go 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. package service
  2. import (
  3. "context"
  4. "net/url"
  5. "strings"
  6. )
  7. // 渠道监控参数校验与归一化辅助函数。
  8. // 校验失败一律返回 channel_monitor_const.go 中预定义的 Err* 错误,错误信息不含具体 IP/hostname,避免泄露内网拓扑。
  9. // monitorProviders 渠道监控支持的全部 provider(与迁移 226 的 CHECK 约束一致)。
  10. // 不再以 adapter 表为唯一来源:antigravity 没有探活 adapter,但支持配额模式。
  11. //
  12. //nolint:gochecknoglobals // 静态查表,初始化后不变。
  13. var monitorProviders = map[string]struct{}{
  14. MonitorProviderOpenAI: {},
  15. MonitorProviderAnthropic: {},
  16. MonitorProviderGemini: {},
  17. MonitorProviderGrok: {},
  18. MonitorProviderAntigravity: {},
  19. MonitorProviderKimi: {},
  20. MonitorProviderZhipu: {},
  21. MonitorProviderDeepseek: {},
  22. }
  23. // probeCapableProviders 支持探活(probe / quota_probe)的 provider。
  24. // antigravity 上游无 Chat/Responses 可打(仅 IDE 代理形态),只允许配额模式。
  25. //
  26. //nolint:gochecknoglobals // 静态查表,初始化后不变。
  27. var probeCapableProviders = map[string]struct{}{
  28. MonitorProviderOpenAI: {},
  29. MonitorProviderAnthropic: {},
  30. MonitorProviderGemini: {},
  31. MonitorProviderGrok: {},
  32. MonitorProviderKimi: {},
  33. MonitorProviderZhipu: {},
  34. MonitorProviderDeepseek: {},
  35. }
  36. // validateProvider 校验 provider 字符串。
  37. func validateProvider(p string) error {
  38. if _, ok := monitorProviders[p]; !ok {
  39. return ErrChannelMonitorInvalidProvider
  40. }
  41. return nil
  42. }
  43. // providerSupportsProbe 该 provider 是否注册了探活 adapter(antigravity 为 false)。
  44. func providerSupportsProbe(p string) bool {
  45. _, ok := probeCapableProviders[p]
  46. return ok
  47. }
  48. // defaultCheckMode 空串归一为 probe,保证存量数据与旧客户端兼容。
  49. func defaultCheckMode(checkMode string) string {
  50. if strings.TrimSpace(checkMode) == "" {
  51. return MonitorCheckModeProbe
  52. }
  53. return strings.TrimSpace(checkMode)
  54. }
  55. // monitorCheckModeUsesQuota 该模式是否需要关联账号查配额。
  56. func monitorCheckModeUsesQuota(checkMode string) bool {
  57. return checkMode == MonitorCheckModeQuota || checkMode == MonitorCheckModeQuotaProbe
  58. }
  59. // validateCheckMode 校验 check_mode 与 provider 的组合矩阵:
  60. //
  61. // provider | probe | quota | quota_probe
  62. // ------------------------+-------+-------+------------
  63. // openai/anthropic/... | Y | Y | Y
  64. // antigravity(无 adapter)| N | Y | N
  65. func validateCheckMode(provider, checkMode string) error {
  66. checkMode = defaultCheckMode(checkMode)
  67. switch checkMode {
  68. case MonitorCheckModeProbe, MonitorCheckModeQuota, MonitorCheckModeQuotaProbe:
  69. default:
  70. return ErrChannelMonitorInvalidCheckMode
  71. }
  72. if checkMode != MonitorCheckModeQuota && !providerSupportsProbe(provider) {
  73. return ErrChannelMonitorInvalidCheckMode
  74. }
  75. return nil
  76. }
  77. // validateAPIMode 校验 provider 与 api_mode 的组合。
  78. // responses 只对 OpenAI 有意义;其它 provider 使用 chat_completions 作为默认占位。
  79. func validateAPIMode(provider, apiMode string) error {
  80. apiMode = defaultAPIMode(apiMode)
  81. switch apiMode {
  82. case MonitorAPIModeChatCompletions:
  83. return nil
  84. case MonitorAPIModeResponses:
  85. if provider == "" || provider == MonitorProviderOpenAI {
  86. return nil
  87. }
  88. return ErrChannelMonitorInvalidAPIMode
  89. default:
  90. return ErrChannelMonitorInvalidAPIMode
  91. }
  92. }
  93. // validateInterval 校验 interval_seconds 范围。
  94. func validateInterval(sec int) error {
  95. if sec < monitorMinIntervalSeconds || sec > monitorMaxIntervalSeconds {
  96. return ErrChannelMonitorInvalidInterval
  97. }
  98. return nil
  99. }
  100. // validateJitter 校验 jitter_seconds(调度 ± 随机抖动):
  101. // 非负,且 interval - jitter 不得低于最小检测间隔,防止随机偏移后实际间隔过短打爆上游。
  102. func validateJitter(jitterSec, intervalSec int) error {
  103. if jitterSec < 0 || intervalSec-jitterSec < monitorMinIntervalSeconds {
  104. return ErrChannelMonitorInvalidJitter
  105. }
  106. return nil
  107. }
  108. // validateEndpoint 校验 endpoint:
  109. // - scheme 强制 https(拒绝 http,避免明文凭证 + 部分 SSRF 利用面)
  110. // - 必须为 origin(无 path/query/fragment),防止用户填 https://api.openai.com/v1
  111. // 导致 joinURL 拼出 /v1/v1/chat/completions
  112. // - hostname 不能是 localhost/metadata 等已知元数据 hostname
  113. // - 解析所有 IP,任一落在 loopback/RFC1918/link-local/ULA 段即拒绝(防 SSRF)
  114. //
  115. // 错误信息不暴露具体 IP / hostname,避免泄露内网拓扑。
  116. func validateEndpoint(ep string) error {
  117. ep = strings.TrimSpace(ep)
  118. if ep == "" {
  119. return ErrChannelMonitorInvalidEndpoint
  120. }
  121. u, err := url.Parse(ep)
  122. if err != nil {
  123. return ErrChannelMonitorInvalidEndpoint
  124. }
  125. if u.Scheme != "https" {
  126. return ErrChannelMonitorEndpointScheme
  127. }
  128. if u.Host == "" {
  129. return ErrChannelMonitorInvalidEndpoint
  130. }
  131. if u.Path != "" && u.Path != "/" {
  132. return ErrChannelMonitorEndpointPath
  133. }
  134. if u.RawQuery != "" || u.Fragment != "" {
  135. return ErrChannelMonitorEndpointPath
  136. }
  137. hostname := u.Hostname()
  138. ctx, cancel := context.WithTimeout(context.Background(), monitorEndpointResolveTimeout)
  139. defer cancel()
  140. blocked, err := isPrivateOrLoopbackHost(ctx, hostname)
  141. if err != nil {
  142. return ErrChannelMonitorEndpointUnreachable
  143. }
  144. if blocked {
  145. return ErrChannelMonitorEndpointPrivate
  146. }
  147. return nil
  148. }
  149. // normalizeEndpoint 去除前后空白与末尾 `/`,保证存储统一为 origin。
  150. // validateEndpoint 已确保格式合法(仅 origin),这里只做最终归一化。
  151. func normalizeEndpoint(ep string) string {
  152. ep = strings.TrimSpace(ep)
  153. ep = strings.TrimRight(ep, "/")
  154. return ep
  155. }
  156. // normalizeModels 去除空白、重复模型名。保留输入顺序(map 的迭代顺序无关)。
  157. func normalizeModels(in []string) []string {
  158. if len(in) == 0 {
  159. return []string{}
  160. }
  161. seen := make(map[string]struct{}, len(in))
  162. out := make([]string, 0, len(in))
  163. for _, m := range in {
  164. m = strings.TrimSpace(m)
  165. if m == "" {
  166. continue
  167. }
  168. if _, ok := seen[m]; ok {
  169. continue
  170. }
  171. seen[m] = struct{}{}
  172. out = append(out, m)
  173. }
  174. return out
  175. }
  176. // normalizeMonitorPrimaryModel applies provider/check_mode defaults:
  177. // - pure quota mode never sends requests: placeholder "quota" keeps
  178. // primary_model NOT NULL (history rows / timeline need no special-casing)
  179. // - quota_probe still sends a real probe request: empty model returns ""
  180. // so validateCreateParams / applyMonitorUpdate report
  181. // ErrChannelMonitorMissingPrimaryModel instead of probing model="quota"
  182. // - Grok probing (probe/quota_probe) defaults to the lightweight check model
  183. func normalizeMonitorPrimaryModel(provider, checkMode, model string) string {
  184. model = strings.TrimSpace(model)
  185. if model == "" && defaultCheckMode(checkMode) == MonitorCheckModeQuota {
  186. return MonitorDefaultQuotaModel
  187. }
  188. if model == "" && provider == MonitorProviderGrok {
  189. return MonitorDefaultGrokModel
  190. }
  191. return model
  192. }
  193. // monitorAccountQuotaCapability 校验关联账号能否充当配额数据源,与
  194. // fetchUncached 的路由一一对应(coding→CN 额度端点 / payg→CN 余额端点 /
  195. // 其余→AccountUsageService)。在创建/更新期拦截注定运行期永久 error 的组合:
  196. // - kimi/zhipu/deepseek/minimax coding:GetCodingPlanProvider 须识别官方域名
  197. // (deepseek coding、自定义中转、minimax payg 无法路由额度端点)
  198. // - kimi/zhipu/deepseek/minimax payg:仅 kimi/deepseek 有公开余额端点
  199. // - anthropic:OAuth / Setup Token(API-Key 型无 usage 通道,永久 error)
  200. // - openai:OAuth(API-Key 型无 usage 通道)
  201. // - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行
  202. func monitorAccountQuotaCapability(account *Account) error {
  203. switch account.Platform {
  204. case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
  205. if account.IsCodingPlan() {
  206. if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu && p != PlatformMiniMax {
  207. return ErrChannelMonitorAccountNotSupportable
  208. }
  209. return nil
  210. }
  211. if account.Platform == PlatformZhipu || account.Platform == PlatformMiniMax {
  212. return ErrChannelMonitorAccountNotSupportable
  213. }
  214. return nil
  215. case PlatformAnthropic:
  216. if account.Type == AccountTypeOAuth || account.Type == AccountTypeSetupToken {
  217. return nil
  218. }
  219. return ErrChannelMonitorAccountNotSupportable
  220. case PlatformOpenAI:
  221. if account.Type == AccountTypeOAuth {
  222. return nil
  223. }
  224. return ErrChannelMonitorAccountNotSupportable
  225. default:
  226. return nil
  227. }
  228. }
  229. // defaultAPIMode 空串归一为 chat_completions,保证历史数据与旧客户端兼容。
  230. func defaultAPIMode(apiMode string) string {
  231. if strings.TrimSpace(apiMode) == "" {
  232. return MonitorAPIModeChatCompletions
  233. }
  234. return strings.TrimSpace(apiMode)
  235. }