account_test_service_cn_adaptive.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. package service
  2. import (
  3. "bufio"
  4. "bytes"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "net/url"
  10. "strings"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  13. "github.com/gin-gonic/gin"
  14. )
  15. const accountTestSuppressCompletionContextKey = "account_test_suppress_completion"
  16. // testCNProviderAdaptiveConnection verifies every native endpoint used by an
  17. // adaptive CN-provider account. Zhipu uses Chat Completions plus Anthropic;
  18. // DeepSeek and Kimi additionally use their native Responses endpoints.
  19. func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
  20. testModelID := strings.TrimSpace(modelID)
  21. if testModelID == "" {
  22. testModelID = openai.DefaultTestModel
  23. }
  24. testModelID = account.GetMappedModel(testModelID)
  25. authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
  26. if authToken == "" {
  27. return s.sendErrorAndEnd(c, "No API key available")
  28. }
  29. // The existing Chat probe owns the SSE lifecycle. Suppress intermediate
  30. // completion events until every native adaptive endpoint has passed.
  31. c.Set(accountTestSuppressCompletionContextKey, true)
  32. defer c.Set(accountTestSuppressCompletionContextKey, false)
  33. if err := s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt); err != nil {
  34. return err
  35. }
  36. if err := s.testCNProviderAdaptiveAnthropicConnection(c, account, testModelID, authToken); err != nil {
  37. return err
  38. }
  39. if account.SupportsNativeCNResponses() {
  40. if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil {
  41. return err
  42. }
  43. }
  44. c.Set(accountTestSuppressCompletionContextKey, false)
  45. s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
  46. return nil
  47. }
  48. func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Context, account *Account, testModelID string, authToken string) error {
  49. ctx := c.Request.Context()
  50. baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolAnthropic))
  51. if err != nil {
  52. return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Anthropic base URL: %s", err.Error()))
  53. }
  54. apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages"
  55. payload, err := createTestPayload(testModelID)
  56. if err != nil {
  57. return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic test payload")
  58. }
  59. applyIntelligentPayloadPrompt(ctx, payload)
  60. payloadBytes, _ := json.Marshal(payload)
  61. s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /v1/messages 测试自适应 Anthropic 端点"})
  62. req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes))
  63. if err != nil {
  64. return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic request")
  65. }
  66. req.Header.Set("Content-Type", "application/json")
  67. req.Header.Set("Accept", "text/event-stream")
  68. req.Header.Set("anthropic-version", "2023-06-01")
  69. for key, value := range claude.DefaultHeaders {
  70. req.Header.Set(key, value)
  71. }
  72. req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
  73. // Ollama Cloud Anthropic 兼容端点按 adaptive 实际选用的 Anthropic
  74. // base_url 强制 Bearer,其余保持 extra/default 行为。
  75. setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
  76. account.ApplyHeaderOverrides(req.Header)
  77. resp, err := s.doCNProviderAdaptiveRequest(req, account)
  78. if err != nil {
  79. return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint request failed: %s", err.Error()))
  80. }
  81. defer func() { _ = resp.Body.Close() }()
  82. if resp.StatusCode != http.StatusOK {
  83. body, _ := io.ReadAll(resp.Body)
  84. errMsg := fmt.Sprintf("Adaptive Anthropic endpoint returned %d: %s", resp.StatusCode, string(body))
  85. if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
  86. _ = s.accountRepo.SetError(ctx, account.ID, errMsg)
  87. }
  88. return s.sendErrorAndEnd(c, errMsg)
  89. }
  90. if err := s.processCNProviderAdaptiveAnthropicStream(c, resp.Body); err != nil {
  91. return err
  92. }
  93. s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /v1/messages 验证"})
  94. return nil
  95. }
  96. func (s *AccountTestService) processCNProviderAdaptiveAnthropicStream(c *gin.Context, body io.Reader) error {
  97. reader := bufio.NewReader(body)
  98. for {
  99. line, err := reader.ReadString('\n')
  100. if err != nil {
  101. if err == io.EOF {
  102. return s.sendErrorAndEnd(c, "Adaptive Anthropic stream ended before message_stop")
  103. }
  104. return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic stream read error: %s", err.Error()))
  105. }
  106. line = strings.TrimSpace(line)
  107. if line == "" || !sseDataPrefix.MatchString(line) {
  108. continue
  109. }
  110. jsonStr := sseDataPrefix.ReplaceAllString(line, "")
  111. if jsonStr == "[DONE]" {
  112. return nil
  113. }
  114. var data map[string]any
  115. if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
  116. continue
  117. }
  118. switch eventType, _ := data["type"].(string); eventType {
  119. case "content_block_delta":
  120. if delta, ok := data["delta"].(map[string]any); ok {
  121. if text, ok := delta["text"].(string); ok && text != "" {
  122. s.sendEvent(c, TestEvent{Type: "content", Text: text})
  123. }
  124. }
  125. case "message_stop":
  126. return nil
  127. case "error":
  128. errorMsg := "Unknown error"
  129. if errData, ok := data["error"].(map[string]any); ok {
  130. if message, ok := errData["message"].(string); ok && message != "" {
  131. errorMsg = message
  132. }
  133. }
  134. return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint error: %s", errorMsg))
  135. }
  136. }
  137. }
  138. func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Context, account *Account, testModelID string, authToken string) error {
  139. ctx := c.Request.Context()
  140. baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolResponses))
  141. if err != nil {
  142. return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Responses base URL: %s", err.Error()))
  143. }
  144. apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL)
  145. payload := createOpenAITestPayload(testModelID, false)
  146. // DeepSeek / Kimi native Responses endpoints are stateless and do not need
  147. // the OpenAI probe's synthetic instructions.
  148. delete(payload, "instructions")
  149. applyIntelligentPayloadPrompt(ctx, payload)
  150. payloadBytes, _ := json.Marshal(payload)
  151. payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes)
  152. s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /responses 测试自适应 Responses 端点"})
  153. req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes))
  154. if err != nil {
  155. return s.sendErrorAndEnd(c, "Failed to create adaptive Responses request")
  156. }
  157. req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
  158. req.Header.Set("Content-Type", "application/json")
  159. req.Header.Set("Accept", "text/event-stream")
  160. req.Header.Set("Authorization", "Bearer "+authToken)
  161. applyOpenAICodexProbeHeaders(req.Header)
  162. account.ApplyHeaderOverrides(req.Header)
  163. resp, err := s.doCNProviderAdaptiveRequest(req, account)
  164. if err != nil {
  165. return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Responses endpoint request failed: %s", err.Error()))
  166. }
  167. defer func() { _ = resp.Body.Close() }()
  168. if resp.StatusCode != http.StatusOK {
  169. body, _ := io.ReadAll(resp.Body)
  170. errMsg := fmt.Sprintf("Adaptive Responses endpoint returned %d: %s", resp.StatusCode, string(body))
  171. if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
  172. _ = s.accountRepo.SetError(ctx, account.ID, errMsg)
  173. }
  174. return s.sendErrorAndEnd(c, errMsg)
  175. }
  176. if err := s.processOpenAIStream(c, resp.Body); err != nil {
  177. return err
  178. }
  179. s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /responses 验证"})
  180. return nil
  181. }
  182. func (s *AccountTestService) doCNProviderAdaptiveRequest(req *http.Request, account *Account) (*http.Response, error) {
  183. proxyURL := ""
  184. if account.ProxyID != nil && account.Proxy != nil {
  185. proxyURL = account.Proxy.URL()
  186. }
  187. return s.httpUpstream.DoWithTLS(WithAccountTrafficRequest(req, account), proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
  188. }
  189. // testCNProviderAnthropicConnection verifies the native Anthropic endpoint of a
  190. // CN-provider account explicitly configured with api_protocol=anthropic. Before
  191. // this path existed such accounts fell through to the generic Claude tester,// which (a) appended ?beta=true and (b) defaulted a missing base_url to
  192. // https://api.anthropic.com — sending the provider's API key to Anthropic
  193. // instead of the provider's own Anthropic-compatible endpoint. The probe uses
  194. // GetAnthropicProtocolBaseURL (same resolution as real /v1/messages forwarding,
  195. // including per-platform defaults) and the shared API-key auth header.
  196. func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, account *Account, modelID string) error {
  197. ctx := c.Request.Context()
  198. testModelID := strings.TrimSpace(modelID)
  199. if testModelID == "" {
  200. testModelID = claude.DefaultTestModel
  201. }
  202. testModelID = account.GetMappedModel(testModelID)
  203. authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
  204. if authToken == "" {
  205. return s.sendErrorAndEnd(c, "No API key available")
  206. }
  207. baseURL, err := s.validateUpstreamBaseURL(account.GetAnthropicProtocolBaseURL())
  208. if err != nil {
  209. return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid Anthropic base URL: %s", err.Error()))
  210. }
  211. if hint := cnAnthropicBaseURLMisconfigHint(baseURL); hint != "" {
  212. return s.sendErrorAndEnd(c, hint)
  213. }
  214. apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages"
  215. c.Writer.Header().Set("Content-Type", "text/event-stream")
  216. c.Writer.Header().Set("Cache-Control", "no-cache")
  217. c.Writer.Header().Set("Connection", "keep-alive")
  218. c.Writer.Header().Set("X-Accel-Buffering", "no")
  219. c.Writer.Flush()
  220. payload, err := createTestPayload(testModelID)
  221. if err != nil {
  222. return s.sendErrorAndEnd(c, "Failed to create Anthropic test payload")
  223. }
  224. applyIntelligentPayloadPrompt(ctx, payload)
  225. payloadBytes, _ := json.Marshal(payload)
  226. s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
  227. req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes))
  228. if err != nil {
  229. return s.sendErrorAndEnd(c, "Failed to create Anthropic test request")
  230. }
  231. req.Header.Set("Content-Type", "application/json")
  232. req.Header.Set("Accept", "text/event-stream")
  233. req.Header.Set("anthropic-version", "2023-06-01")
  234. for key, value := range claude.DefaultHeaders {
  235. req.Header.Set(key, value)
  236. }
  237. // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持
  238. // extra/default 行为。
  239. setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL())
  240. account.ApplyHeaderOverrides(req.Header)
  241. resp, err := s.doCNProviderAdaptiveRequest(req, account)
  242. if err != nil {
  243. return s.sendErrorAndEnd(c, fmt.Sprintf("Anthropic endpoint request failed: %s", err.Error()))
  244. }
  245. defer func() { _ = resp.Body.Close() }()
  246. if resp.StatusCode != http.StatusOK {
  247. body, _ := io.ReadAll(resp.Body)
  248. errMsg := fmt.Sprintf("Anthropic endpoint returned %d: %s", resp.StatusCode, string(body))
  249. if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && s.accountRepo != nil {
  250. _ = s.accountRepo.SetError(ctx, account.ID, errMsg)
  251. }
  252. return s.sendErrorAndEnd(c, errMsg)
  253. }
  254. return s.processClaudeStream(c, resp.Body)
  255. }
  256. // cnAnthropicBaseURLMisconfigHint reports an actionable error when an
  257. // anthropic-protocol account's base_url still points at an OpenAI-compatible
  258. // endpoint (paas path, version segment, or chat/completions / responses
  259. // suffix). The naive {base}/v1/messages join would 404 (e.g.
  260. // .../api/paas/v4/v1/messages) with no hint about the actual misconfiguration.
  261. func cnAnthropicBaseURLMisconfigHint(baseURL string) string {
  262. parsed, err := url.Parse(strings.TrimSpace(baseURL))
  263. if err != nil || parsed.Scheme == "" || parsed.Host == "" {
  264. return ""
  265. }
  266. path := strings.ToLower(strings.TrimRight(parsed.Path, "/"))
  267. if path == "" {
  268. return ""
  269. }
  270. openAICompatShaped := strings.Contains(path, "/paas/") ||
  271. strings.HasSuffix(path, "/chat/completions") ||
  272. strings.HasSuffix(path, "/responses") ||
  273. openAIBaseURLHasVersionSuffix(path)
  274. if !openAICompatShaped {
  275. return ""
  276. }
  277. return fmt.Sprintf(
  278. "API protocol is anthropic but base_url (%s) looks like an OpenAI-compatible endpoint; "+
  279. "requests would hit {base}/v1/messages and 404. Set base_url to the provider's Anthropic endpoint "+
  280. "(e.g. https://open.bigmodel.cn/api/anthropic) or switch api_protocol to chat_completions/adaptive.",
  281. baseURL,
  282. )
  283. }