diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go new file mode 100644 index 00000000..6e33948c --- /dev/null +++ b/backend/internal/pkg/claude/constants.go @@ -0,0 +1,32 @@ +package claude + +// Claude Code 客户端相关常量 + +// Beta header 常量 +const ( + BetaOAuth = "oauth-2025-04-20" + BetaClaudeCode = "claude-code-20250219" + BetaInterleavedThinking = "interleaved-thinking-2025-05-14" + BetaFineGrainedToolStreaming = "fine-grained-tool-streaming-2025-05-14" +) + +// DefaultBetaHeader Claude Code 客户端默认的 anthropic-beta header +const DefaultBetaHeader = BetaClaudeCode + "," + BetaOAuth + "," + BetaInterleavedThinking + "," + BetaFineGrainedToolStreaming + +// HaikuBetaHeader Haiku 模型使用的 anthropic-beta header(不需要 claude-code beta) +const HaikuBetaHeader = BetaOAuth + "," + BetaInterleavedThinking + +// Claude Code 客户端默认请求头 +var DefaultHeaders = map[string]string{ + "User-Agent": "claude-cli/2.0.62 (external, cli)", + "X-Stainless-Lang": "js", + "X-Stainless-Package-Version": "0.52.0", + "X-Stainless-OS": "Linux", + "X-Stainless-Arch": "x64", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v22.14.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Timeout": "60", + "X-App": "cli", + "Anthropic-Dangerous-Direct-Browser-Access": "true", +} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 7a531b06..e5344c1e 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "sub2api/internal/pkg/claude" "sub2api/internal/repository" "github.com/gin-gonic/gin" @@ -62,7 +63,7 @@ func generateSessionString() string { return fmt.Sprintf("user_%s_account__session_%s", hex64, sessionUUID) } -// createTestPayload creates a minimal test request payload for OAuth/Setup Token accounts +// createTestPayload creates a Claude Code style test request payload func createTestPayload() map[string]interface{} { return map[string]interface{}{ "model": testModel, @@ -98,22 +99,8 @@ func createTestPayload() map[string]interface{} { } } -// createApiKeyTestPayload creates a simpler test request payload for API Key accounts -func createApiKeyTestPayload(model string) map[string]interface{} { - return map[string]interface{}{ - "model": model, - "messages": []map[string]interface{}{ - { - "role": "user", - "content": "hi", - }, - }, - "max_tokens": 1024, - "stream": true, - } -} - // TestAccountConnection tests an account's connection by sending a test request +// All account types use full Claude Code client characteristics, only auth header differs func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int64) error { ctx := c.Request.Context() @@ -123,14 +110,14 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.sendErrorAndEnd(c, "Account not found") } - // Determine authentication method based on account type + // Determine authentication method and API URL var authToken string - var authType string // "bearer" for OAuth, "apikey" for API Key + var useBearer bool var apiURL string if account.IsOAuth() { - // OAuth or Setup Token account - authType = "bearer" + // OAuth or Setup Token - use Bearer token + useBearer = true apiURL = testClaudeAPIURL authToken = account.GetCredential("access_token") if authToken == "" { @@ -141,7 +128,7 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int needRefresh := false if expiresAtStr := account.GetCredential("expires_at"); expiresAtStr != "" { expiresAt, err := strconv.ParseInt(expiresAtStr, 10, 64) - if err == nil && time.Now().Unix()+300 > expiresAt { // 5 minute buffer + if err == nil && time.Now().Unix()+300 > expiresAt { needRefresh = true } } @@ -154,19 +141,17 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int authToken = tokenInfo.AccessToken } } else if account.Type == "apikey" { - // API Key account - authType = "apikey" + // API Key - use x-api-key header + useBearer = false authToken = account.GetCredential("api_key") if authToken == "" { return s.sendErrorAndEnd(c, "No API key available") } - // Get base URL (use default if not set) apiURL = account.GetBaseURL() if apiURL == "" { apiURL = "https://api.anthropic.com" } - // Append /v1/messages endpoint apiURL = strings.TrimSuffix(apiURL, "/") + "/v1/messages" } else { return s.sendErrorAndEnd(c, fmt.Sprintf("Unsupported account type: %s", account.Type)) @@ -179,37 +164,32 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int c.Writer.Header().Set("X-Accel-Buffering", "no") c.Writer.Flush() - // Create test request payload - var payload map[string]interface{} - var actualModel string - if authType == "apikey" { - // Use simpler payload for API Key (without Claude Code specific fields) - // Apply model mapping if configured - actualModel = account.GetMappedModel(testModel) - payload = createApiKeyTestPayload(actualModel) - } else { - actualModel = testModel - payload = createTestPayload() - } + // Create Claude Code style payload (same for all account types) + payload := createTestPayload() payloadBytes, _ := json.Marshal(payload) - // Send test_start event with model info - s.sendEvent(c, TestEvent{Type: "test_start", Model: actualModel}) + // Send test_start event + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModel}) req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payloadBytes)) if err != nil { return s.sendErrorAndEnd(c, "Failed to create request") } - // Set headers based on auth type + // Set common headers req.Header.Set("Content-Type", "application/json") req.Header.Set("anthropic-version", "2023-06-01") + req.Header.Set("anthropic-beta", claude.DefaultBetaHeader) - if authType == "bearer" { + // Apply Claude Code client headers + for key, value := range claude.DefaultHeaders { + req.Header.Set(key, value) + } + + // Set authentication header + if useBearer { req.Header.Set("Authorization", "Bearer "+authToken) - req.Header.Set("anthropic-beta", "prompt-caching-2024-07-31,interleaved-thinking-2025-05-14,output-128k-2025-02-19") } else { - // API Key uses x-api-key header req.Header.Set("x-api-key", authToken) } @@ -252,7 +232,6 @@ func (s *AccountTestService) processStream(c *gin.Context, body io.Reader) error line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { - // Stream ended, send complete event s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) return nil } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 55dfc784..5991a2db 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -20,6 +20,7 @@ import ( "sub2api/internal/config" "sub2api/internal/model" + "sub2api/internal/pkg/claude" "sub2api/internal/repository" "github.com/gin-gonic/gin" @@ -602,13 +603,10 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // getBetaHeader 处理anthropic-beta header // 对于OAuth账号,需要确保包含oauth-2025-04-20 func (s *GatewayService) getBetaHeader(body []byte, clientBetaHeader string) string { - const oauthBeta = "oauth-2025-04-20" - const claudeCodeBeta = "claude-code-20250219" - // 如果客户端传了anthropic-beta if clientBetaHeader != "" { // 已包含oauth beta则直接返回 - if strings.Contains(clientBetaHeader, oauthBeta) { + if strings.Contains(clientBetaHeader, claude.BetaOAuth) { return clientBetaHeader } @@ -621,7 +619,7 @@ func (s *GatewayService) getBetaHeader(body []byte, clientBetaHeader string) str // 在claude-code-20250219后面插入oauth beta claudeCodeIdx := -1 for i, p := range parts { - if p == claudeCodeBeta { + if p == claude.BetaClaudeCode { claudeCodeIdx = i break } @@ -631,13 +629,13 @@ func (s *GatewayService) getBetaHeader(body []byte, clientBetaHeader string) str // 在claude-code后面插入 newParts := make([]string, 0, len(parts)+1) newParts = append(newParts, parts[:claudeCodeIdx+1]...) - newParts = append(newParts, oauthBeta) + newParts = append(newParts, claude.BetaOAuth) newParts = append(newParts, parts[claudeCodeIdx+1:]...) return strings.Join(newParts, ",") } // 没有claude-code,放在第一位 - return oauthBeta + "," + clientBetaHeader + return claude.BetaOAuth + "," + clientBetaHeader } // 客户端没传,根据模型生成 @@ -651,10 +649,10 @@ func (s *GatewayService) getBetaHeader(body []byte, clientBetaHeader string) str // haiku模型不需要claude-code beta if strings.Contains(strings.ToLower(modelID), "haiku") { - return "oauth-2025-04-20,interleaved-thinking-2025-05-14" + return claude.HaikuBetaHeader } - return "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14" + return claude.DefaultBetaHeader } func (s *GatewayService) forceRefreshToken(ctx context.Context, account *model.Account) (string, string, error) {