From 521d61b489f8c90c278425d389d4cf84556cf75f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A1=BE=E9=9B=A8=E6=99=A8?= Date: Sat, 2 May 2026 02:19:16 +0800 Subject: [PATCH] feat(codearts): add CLI login, fix signer/payload, add thinking provider --- cmd/server/main.go | 4 + config.example.yaml | 4 +- internal/api/server.go | 28 +-- internal/auth/codearts/codearts_auth.go | 2 +- internal/auth/codearts/oauth_web.go | 32 ++-- internal/auth/codearts/signer.go | 4 +- internal/cmd/auth_manager.go | 1 + internal/cmd/codearts_login.go | 37 ++++ internal/config/config.go | 4 +- .../runtime/executor/codearts_executor.go | 121 +++++++++++-- .../executor/helps/thinking_providers.go | 1 + internal/thinking/provider/codearts/apply.go | 28 +++ internal/watcher/watcher.go | 34 ++-- sdk/auth/codearts.go | 171 ++++++++++++++++++ sdk/auth/refresh_registry.go | 1 + 15 files changed, 398 insertions(+), 74 deletions(-) create mode 100644 internal/cmd/codearts_login.go create mode 100644 internal/thinking/provider/codearts/apply.go create mode 100644 sdk/auth/codearts.go diff --git a/cmd/server/main.go b/cmd/server/main.go index e689b2c3f7..138e21c9c5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -99,6 +99,7 @@ func main() { var codeBuddyAILogin bool var btLogin bool var qoderLogin bool + var codeartsLogin bool var projectID string var vertexImport string var vertexImportPrefix string @@ -141,6 +142,7 @@ func main() { flag.BoolVar(&codeBuddyAILogin, "codebuddy-ai-login", false, "Login to CodeBuddy AI (www.codebuddy.ai) using browser OAuth flow") flag.BoolVar(&btLogin, "bt-login", false, "Login to BaoTa Panel AI using phone and password") flag.BoolVar(&qoderLogin, "qoder-login", false, "Login to Qoder using PKCE browser flow") + flag.BoolVar(&codeartsLogin, "codearts-login", false, "Login to HuaweiCloud CodeArts using OAuth") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") @@ -595,6 +597,8 @@ func main() { cmd.DoBTLogin(cfg) } else if qoderLogin { cmd.DoQoderLogin(cfg, options) + } else if codeartsLogin { + cmd.DoCodeArtsLogin(cfg, options) } else { // In cloud deploy mode without config file, just wait for shutdown signals if isCloudDeploy && !configFileExists { diff --git a/config.example.yaml b/config.example.yaml index 87a7543a59..f932279333 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -348,7 +348,7 @@ nonstream-keepalive-interval: 0 # Global OAuth model name aliases (per channel) # These aliases rename model IDs for both model listing and request routing. -# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kiro, github-copilot, kimi. +# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kiro, github-copilot, kimi, codearts. # NOTE: Aliases do not apply to gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, or ampcode. # NOTE: Because aliases affect the merged /v1 model list and merged request routing, overlapping # client-visible names can become ambiguous across providers. /api/provider/{provider}/... helps @@ -398,7 +398,7 @@ nonstream-keepalive-interval: 0 # alias: "copilot-gpt5" # OAuth provider excluded models -# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, qwen, iflow, kiro, github-copilot. +# Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kiro, github-copilot, codearts. # oauth-excluded-models: # gemini-cli: # - "gemini-2.5-pro" # exclude specific models (exact match) diff --git a/internal/api/server.go b/internal/api/server.go index ebfe88b1dd..a5c5f0bcc0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -306,12 +306,12 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk s.registerManagementRoutes() } - // === CLIProxyAPIPlus 扩展: 注册 Kiro OAuth Web 路由 === + // === CLIProxyAPIPlus extension: Register Kiro OAuth Web routes === kiroOAuthHandler := kiro.NewOAuthWebHandler(cfg) kiroOAuthHandler.RegisterRoutes(engine) log.Info("Kiro OAuth Web routes registered at /v0/oauth/kiro/*") - // === CLIProxyAPIPlus 扩展: 注册 CodeArts OAuth Web 路由 === + // === CLIProxyAPIPlus extension: Register CodeArts OAuth Web routes === codeArtsOAuthHandler := codearts.NewOAuthWebHandler(cfg) codeArtsOAuthHandler.RegisterRoutes(engine) log.Info("CodeArts OAuth Web routes registered at /v0/oauth/codearts/*") @@ -702,19 +702,19 @@ func (s *Server) registerManagementRoutes() { mgmt.GET("/gitlab-auth-url", s.mgmt.RequestGitLabToken) mgmt.POST("/gitlab-auth-url", s.mgmt.RequestGitLabPATToken) mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken) - mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) - mgmt.GET("/kilo-auth-url", s.mgmt.RequestKiloToken) - mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken) - mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) - mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) - mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken) - mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken) - mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken) - mgmt.GET("/qoder-auth-url", s.mgmt.RequestQoderToken) - mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) - mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) - } + mgmt.GET("/antigravity-auth-url", s.mgmt.RequestAntigravityToken) + mgmt.GET("/kilo-auth-url", s.mgmt.RequestKiloToken) + mgmt.GET("/kimi-auth-url", s.mgmt.RequestKimiToken) + mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken) + mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken) + mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken) + mgmt.GET("/cursor-auth-url", s.mgmt.RequestCursorToken) + mgmt.GET("/github-auth-url", s.mgmt.RequestGitHubToken) + mgmt.GET("/qoder-auth-url", s.mgmt.RequestQoderToken) + mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback) + mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus) } +} func (s *Server) managementAvailabilityMiddleware() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/internal/auth/codearts/codearts_auth.go b/internal/auth/codearts/codearts_auth.go index 487e49ee93..6a90c25873 100644 --- a/internal/auth/codearts/codearts_auth.go +++ b/internal/auth/codearts/codearts_auth.go @@ -237,7 +237,7 @@ func (a *CodeArtsAuth) RefreshToken(ctx context.Context, token *CodeArtsTokenDat req.Header.Set("Access-Key", token.AK) // Sign with SDK-HMAC-SHA256 - SignRequest(req, token.AK, token.SK, token.SecurityToken) + SignRequest(req, body, token.AK, token.SK, token.SecurityToken) resp, err := a.httpClient.Do(req) if err != nil { diff --git a/internal/auth/codearts/oauth_web.go b/internal/auth/codearts/oauth_web.go index 709458b885..7388f5965a 100644 --- a/internal/auth/codearts/oauth_web.go +++ b/internal/auth/codearts/oauth_web.go @@ -22,11 +22,11 @@ import ( type sessionStatus string const ( - sPending sessionStatus = "pending" - sWaitingCB sessionStatus = "waiting_callback" - sPolling sessionStatus = "polling" - sSuccess sessionStatus = "success" - sFailed sessionStatus = "failed" + sPending sessionStatus = "pending" + sWaitingCB sessionStatus = "waiting_callback" + sPolling sessionStatus = "polling" + sSuccess sessionStatus = "success" + sFailed sessionStatus = "failed" ) type webSession struct { @@ -301,17 +301,17 @@ func (h *OAuthWebHandler) saveTokenToFile(tokenData *CodeArtsTokenData) { // Save in the same format as the file synthesizer expects: // { "type": "codearts", ... } storage := map[string]interface{}{ - "type": "codearts", - "ak": tokenData.AK, - "sk": tokenData.SK, - "security_token": tokenData.SecurityToken, - "x_auth_token": tokenData.XAuthToken, - "expires_at": tokenData.ExpiresAt.Format(time.RFC3339), - "user_id": tokenData.UserID, - "user_name": tokenData.UserName, - "domain_id": tokenData.DomainID, - "email": tokenData.Email, - "last_refresh": time.Now().Format(time.RFC3339), + "type": "codearts", + "ak": tokenData.AK, + "sk": tokenData.SK, + "security_token": tokenData.SecurityToken, + "x_auth_token": tokenData.XAuthToken, + "expires_at": tokenData.ExpiresAt.Format(time.RFC3339), + "user_id": tokenData.UserID, + "user_name": tokenData.UserName, + "domain_id": tokenData.DomainID, + "email": tokenData.Email, + "last_refresh": time.Now().Format(time.RFC3339), } data, err := json.MarshalIndent(storage, "", " ") diff --git a/internal/auth/codearts/signer.go b/internal/auth/codearts/signer.go index 24c82421c4..c28dd77723 100644 --- a/internal/auth/codearts/signer.go +++ b/internal/auth/codearts/signer.go @@ -18,7 +18,7 @@ import ( // - Single-step HMAC (no derived key) // - Path must end with "/" // - Algorithm name is "SDK-HMAC-SHA256" -func SignRequest(req *http.Request, ak, sk, securityToken string) { +func SignRequest(req *http.Request, body []byte, ak, sk, securityToken string) { now := time.Now().UTC() timeStr := now.Format("20060102T150405Z") @@ -72,7 +72,7 @@ func SignRequest(req *http.Request, ak, sk, securityToken string) { signedHeadersStr := strings.Join(signedHeaderKeys, ";") // Body hash (empty for GET, or use existing hash) - bodyHash := sha256Hex([]byte("")) + bodyHash := sha256Hex(body) canonicalReq := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", method, path, canonicalQuery, diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index 0619073c8c..53fce6d759 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -25,6 +25,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewCodeBuddyAIAuthenticator(), sdkAuth.NewCursorAuthenticator(), sdkAuth.NewQoderAuthenticator(), + sdkAuth.NewCodeArtsAuthenticator(), ) return manager } diff --git a/internal/cmd/codearts_login.go b/internal/cmd/codearts_login.go new file mode 100644 index 0000000000..0eedf0e9fa --- /dev/null +++ b/internal/cmd/codearts_login.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + log "github.com/sirupsen/logrus" +) + +func DoCodeArtsLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + manager := newAuthManager() + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + CallbackPort: options.CallbackPort, + Metadata: map[string]string{}, + } + + record, savedPath, err := manager.Login(context.Background(), "codearts", cfg, authOpts) + if err != nil { + log.Errorf("CodeArts authentication failed: %v", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + if record != nil && record.Label != "" { + fmt.Printf("Authenticated as %s\n", record.Label) + } + fmt.Println("CodeArts authentication successful!") +} diff --git a/internal/config/config.go b/internal/config/config.go index b74ecdc979..ce18a2c59c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -138,12 +138,12 @@ type Config struct { AmpCode AmpCode `yaml:"ampcode" json:"ampcode"` // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries. - // Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kiro, github-copilot, kimi. + // Supported channels: gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kiro, github-copilot, kimi, codearts. OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"` // OAuthModelAlias defines global model name aliases for OAuth/file-backed auth channels. // These aliases affect both model listing and model routing for supported channels: - // gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kiro, github-copilot, kimi. + // gemini-cli, vertex, aistudio, antigravity, claude, codex, iflow, kiro, github-copilot, kimi, codearts. // // NOTE: This does not apply to existing per-credential model alias features under: // gemini-api-key, codex-api-key, claude-api-key, openai-compatibility, vertex-api-key, and ampcode. diff --git a/internal/runtime/executor/codearts_executor.go b/internal/runtime/executor/codearts_executor.go index db0e6d5f66..da9dc02adb 100644 --- a/internal/runtime/executor/codearts_executor.go +++ b/internal/runtime/executor/codearts_executor.go @@ -4,6 +4,8 @@ import ( "bufio" "bytes" "context" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -11,6 +13,7 @@ import ( "strings" "time" + "github.com/google/uuid" codearts "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codearts" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor/helps" @@ -55,14 +58,31 @@ func (e *CodeArtsExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth. return fmt.Errorf("codearts: missing AK/SK credentials") } + var bodyBytes []byte + if req.Body != nil { + bodyBytes, _ = io.ReadAll(req.Body) + req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + req.ContentLength = int64(len(bodyBytes)) + } + + traceID := generateTraceID() + req.Header.Set("User-Agent", codeArtsUserAgent) + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Content-Type", "application/json") req.Header.Set("Agent-Type", "ChatAgent") req.Header.Set("Client-Version", "Vscode_26.3.5") + req.Header.Set("Heartbeat-Enable", "true") + req.Header.Set("Ide-Name", "CodeArts Agent") + req.Header.Set("Ide-Version", "1.96.4") + req.Header.Set("Is-Confidential", "false") req.Header.Set("Plugin-Name", "snap_vscode") req.Header.Set("Plugin-Version", "26.3.5") - req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Language", "zh-cn") + req.Header.Set("X-Snap-Traceid", traceID) - codearts.SignRequest(req, ak, sk, securityToken) + codearts.SignRequest(req, bodyBytes, ak, sk, securityToken) return nil } @@ -96,7 +116,9 @@ func (e *CodeArtsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, } } - payload := buildCodeArtsPayload(req.Payload, baseModel, agentID, opts) + userID := extractUserID(auth) + + payload := buildCodeArtsPayload(req.Payload, baseModel, agentID, userID, opts) httpReq, err := http.NewRequestWithContext(ctx, "POST", codeartsChatURL, bytes.NewReader(payload)) if err != nil { @@ -113,7 +135,7 @@ func (e *CodeArtsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, body, _ := io.ReadAll(httpResp.Body) return resp, statusErr{ code: httpResp.StatusCode, - msg: fmt.Sprintf("codearts: API returned %d: %s", httpResp.StatusCode, string(body)), + msg: fmt.Sprintf("codearts: API returned %d: %s", httpResp.StatusCode, string(body)), } } @@ -190,7 +212,9 @@ func (e *CodeArtsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth } } - payload := buildCodeArtsPayload(req.Payload, baseModel, agentID, opts) + userID := extractUserID(auth) + + payload := buildCodeArtsPayload(req.Payload, baseModel, agentID, userID, opts) httpReq, err := http.NewRequestWithContext(ctx, "POST", codeartsChatURL, bytes.NewReader(payload)) if err != nil { @@ -207,7 +231,7 @@ func (e *CodeArtsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth httpResp.Body.Close() return nil, statusErr{ code: httpResp.StatusCode, - msg: fmt.Sprintf("codearts: API returned %d: %s", httpResp.StatusCode, string(body)), + msg: fmt.Sprintf("codearts: API returned %d: %s", httpResp.StatusCode, string(body)), } } @@ -356,8 +380,28 @@ func metadataStr(m map[string]any, key string) string { return "" } +func extractUserID(auth *cliproxyauth.Auth) string { + if auth.Metadata != nil { + if uid, ok := auth.Metadata["user_id"].(string); ok { + return uid + } + if did, ok := auth.Metadata["domain_id"].(string); ok { + return did + } + } + return "" +} + +func generateTraceID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%032d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + // buildCodeArtsPayload converts the OpenAI-format payload to CodeArts format. -func buildCodeArtsPayload(openaiPayload []byte, modelName, agentID string, opts cliproxyexecutor.Options) []byte { +func buildCodeArtsPayload(openaiPayload []byte, modelName, agentID, userID string, opts cliproxyexecutor.Options) []byte { messages := gjson.GetBytes(openaiPayload, "messages") if !messages.Exists() { log.Warn("codearts: no messages found in payload") @@ -374,37 +418,74 @@ func buildCodeArtsPayload(openaiPayload []byte, modelName, agentID string, opts case "system": formattedContent = "[System]\n" + content case "assistant": - formattedContent = "[Assistant]\n" + content - case "user": - formattedContent = content + toolCalls := msg.Get("tool_calls") + if toolCalls.Exists() && len(toolCalls.Array()) > 0 { + var parts []string + parts = append(parts, "[Assistant]\n"+content) + for _, tc := range toolCalls.Array() { + name := tc.Get("function.name").String() + id := tc.Get("id").String() + args := tc.Get("function.arguments").String() + parts = append(parts, fmt.Sprintf("[Tool Call: %s] (id: %s)\n%s", name, id, args)) + } + formattedContent = strings.Join(parts, "\n") + } else { + formattedContent = "[Assistant]\n" + content + } case "tool": toolName := msg.Get("name").String() + toolID := msg.Get("tool_call_id").String() if toolName == "" { toolName = "unknown" } - formattedContent = fmt.Sprintf("[Tool Result: %s]\n%s", toolName, content) + formattedContent = fmt.Sprintf("[Tool Result: %s] (id: %s)\n%s", toolName, toolID, content) + case "user": + formattedContent = content default: formattedContent = content } codeArtsMessages = append(codeArtsMessages, map[string]string{ - "role": role, + "type": "text", "content": formattedContent, }) } - request := map[string]interface{}{ - "messages": codeArtsMessages, - "model": modelName, - "agent_id": agentID, - "stream": true, + taskParameters := map[string]interface{}{ + "is_intent_recognition": false, + "W3_Search": false, + "codebase_search": false, + "related_question": true, + "preferred_language": "zh-cn", + "enable_code_interpreter": false, + "projectLevelPrompt": "", + "contexts": []interface{}{}, + "expert_rules": []interface{}{}, + "ide": "CodeArts Agent", + "routerVersion": "v2", + "isNewClient": true, + "features": map[string]interface{}{"support_end_tag": true}, } - if maxTokens := gjson.GetBytes(openaiPayload, "max_tokens"); maxTokens.Exists() { - request["max_tokens"] = maxTokens.Value() + if tools := gjson.GetBytes(openaiPayload, "tools"); tools.Exists() { + taskParameters["tools"] = tools.Value() } if temp := gjson.GetBytes(openaiPayload, "temperature"); temp.Exists() { - request["temperature"] = temp.Value() + taskParameters["temperature"] = temp.Value() + } + + request := map[string]interface{}{ + "chat_id": uuid.New().String(), + "messages": codeArtsMessages, + "client": "IDE", + "task": "chat", + "task_parameters": taskParameters, + "batch_task_parameters": []interface{}{}, + "attempt": 1, + "user_id": userID, + "parent_message_id": "", + "is_delta_response": true, + "model_id": modelName, } result, err := json.Marshal(request) diff --git a/internal/runtime/executor/helps/thinking_providers.go b/internal/runtime/executor/helps/thinking_providers.go index bbd019624d..4c79044e87 100644 --- a/internal/runtime/executor/helps/thinking_providers.go +++ b/internal/runtime/executor/helps/thinking_providers.go @@ -3,6 +3,7 @@ package helps import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/antigravity" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/claude" + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codearts" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/codex" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/gemini" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking/provider/geminicli" diff --git a/internal/thinking/provider/codearts/apply.go b/internal/thinking/provider/codearts/apply.go new file mode 100644 index 0000000000..38d615ed80 --- /dev/null +++ b/internal/thinking/provider/codearts/apply.go @@ -0,0 +1,28 @@ +package codearts + +import ( + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" +) + +type Applier struct{} + +var _ thinking.ProviderApplier = (*Applier)(nil) + +func NewApplier() *Applier { + return &Applier{} +} + +func init() { + thinking.RegisterProvider("codearts", NewApplier()) +} + +func (a *Applier) Apply(body []byte, config thinking.ThinkingConfig, modelInfo *registry.ModelInfo) ([]byte, error) { + if len(body) == 0 { + return body, nil + } + if modelInfo == nil || modelInfo.Thinking == nil { + return body, nil + } + return body, nil +} diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index e5c1aeb04a..ebc5d9dccc 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -158,12 +158,12 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { return snapshotCoreAuths(cfg, w.authDir) } -// NotifyTokenRefreshed 处理后台刷新器的 token 更新通知 -// 当后台刷新器成功刷新 token 后调用此方法,更新内存中的 Auth 对象 -// tokenID: token 文件名(如 kiro-xxx.json) -// accessToken: 新的 access token -// refreshToken: 新的 refresh token -// expiresAt: 新的过期时间 +// NotifyTokenRefreshed handles token update notifications from the background refresher +// When the background refresher successfully refreshes a token, this method updates the in-memory Auth object +// tokenID: token file name (e.g. kiro-xxx.json) +// accessToken: new access token +// refreshToken: new refresh token +// expiresAt: new expiration time func (w *Watcher) NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expiresAt string) { if w == nil { return @@ -172,37 +172,37 @@ func (w *Watcher) NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expir w.clientsMutex.Lock() defer w.clientsMutex.Unlock() - // 遍历 currentAuths,找到匹配的 Auth 并更新 + // Iterate currentAuths to find and update the matching Auth updated := false for id, auth := range w.currentAuths { if auth == nil || auth.Metadata == nil { continue } - // 检查是否是 kiro 类型的 auth + // Check if this is a kiro-type auth authType, _ := auth.Metadata["type"].(string) if authType != "kiro" { continue } - // 多种匹配方式,解决不同来源的 auth 对象字段差异 + // Multiple matching strategies to handle field differences across auth sources matched := false - // 1. 通过 auth.ID 匹配(ID 可能包含文件名) + // 1. Match by auth.ID (ID may contain the file name) if !matched && auth.ID != "" { if auth.ID == tokenID || strings.HasSuffix(auth.ID, "/"+tokenID) || strings.HasSuffix(auth.ID, "\\"+tokenID) { matched = true } - // ID 可能是 "kiro-xxx" 格式(无扩展名),tokenID 是 "kiro-xxx.json" + // ID may be "kiro-xxx" format (no extension), tokenID is "kiro-xxx.json" if !matched && strings.TrimSuffix(tokenID, ".json") == auth.ID { matched = true } } - // 2. 通过 auth.Attributes["path"] 匹配 + // 2. Match by auth.Attributes["path"] if !matched && auth.Attributes != nil { if authPath := auth.Attributes["path"]; authPath != "" { - // 提取文件名部分进行比较 + // Extract the file name portion for comparison pathBase := authPath if idx := strings.LastIndexAny(authPath, "/\\"); idx >= 0 { pathBase = authPath[idx+1:] @@ -213,7 +213,7 @@ func (w *Watcher) NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expir } } - // 3. 通过 auth.FileName 匹配(原有逻辑) + // 3. Match by auth.FileName (original logic) if !matched && auth.FileName != "" { if auth.FileName == tokenID || strings.HasSuffix(auth.FileName, "/"+tokenID) || strings.HasSuffix(auth.FileName, "\\"+tokenID) { matched = true @@ -221,7 +221,7 @@ func (w *Watcher) NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expir } if matched { - // 更新内存中的 token + // Update the in-memory token auth.Metadata["access_token"] = accessToken auth.Metadata["refresh_token"] = refreshToken auth.Metadata["expires_at"] = expiresAt @@ -232,7 +232,7 @@ func (w *Watcher) NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expir log.Infof("watcher: updated in-memory auth for token %s (auth ID: %s)", tokenID, id) updated = true - // 同时更新 runtimeAuths 中的副本(如果存在) + // Also update the copy in runtimeAuths if present if w.runtimeAuths != nil { if runtimeAuth, ok := w.runtimeAuths[id]; ok && runtimeAuth != nil { if runtimeAuth.Metadata == nil { @@ -247,7 +247,7 @@ func (w *Watcher) NotifyTokenRefreshed(tokenID, accessToken, refreshToken, expir } } - // 发送更新通知到 authQueue + // Send update notification to authQueue if w.authQueue != nil { go func(authClone *coreauth.Auth) { update := AuthUpdate{ diff --git a/sdk/auth/codearts.go b/sdk/auth/codearts.go new file mode 100644 index 0000000000..64b2f6e6f5 --- /dev/null +++ b/sdk/auth/codearts.go @@ -0,0 +1,171 @@ +package auth + +import ( + "context" + "crypto/rand" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codearts" + "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +var codeartsRefreshLead = 4 * time.Hour + +type CodeArtsAuthenticator struct{} + +func NewCodeArtsAuthenticator() Authenticator { return &CodeArtsAuthenticator{} } + +func (CodeArtsAuthenticator) Provider() string { return "codearts" } + +func (CodeArtsAuthenticator) RefreshLead() *time.Duration { + return &codeartsRefreshLead +} + +type codeartsCallbackResult struct { + Identifier string + Redirect string + Error string +} + +func (a CodeArtsAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("cliproxy auth: configuration is required") + } + if ctx == nil { + ctx = context.Background() + } + if opts == nil { + opts = &LoginOptions{} + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("codearts: failed to find free port: %w", err) + } + port := listener.Addr().(*net.TCPAddr).Port + + cbChan := make(chan codeartsCallbackResult, 1) + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + identifier := r.URL.Query().Get("identifier") + redirect := r.URL.Query().Get("redirect") + cbChan <- codeartsCallbackResult{ + Identifier: identifier, + Redirect: redirect, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(`CodeArts Login` + + `` + + `
` + + `

✓ Login Successful

` + + `

You can close this window and return to the terminal.

` + + `
`)) + }) + + srv := &http.Server{Handler: mux} + go func() { + if errServe := srv.Serve(listener); errServe != nil && !strings.Contains(errServe.Error(), "Server closed") { + log.Warnf("codearts callback server error: %v", errServe) + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + ticketID := generateCodeArtsTicketID() + codeartsAuth := codearts.NewCodeArtsAuth(nil) + authURL := codeartsAuth.AuthorizationURL(ticketID, port) + + if !opts.NoBrowser { + fmt.Println("Opening browser for CodeArts authentication") + if !browser.IsAvailable() { + log.Warn("No browser available; please open the URL manually") + util.PrintSSHTunnelInstructions(port) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } else if errOpen := browser.OpenURL(authURL); errOpen != nil { + log.Warnf("Failed to open browser automatically: %v", errOpen) + util.PrintSSHTunnelInstructions(port) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + } else { + util.PrintSSHTunnelInstructions(port) + fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL) + } + + fmt.Println("Waiting for CodeArts authentication callback...") + + var cbRes codeartsCallbackResult + timeoutTimer := time.NewTimer(5 * time.Minute) + defer timeoutTimer.Stop() + + select { + case cbRes = <-cbChan: + case <-timeoutTimer.C: + return nil, fmt.Errorf("codearts: authentication timed out") + } + + if cbRes.Error != "" { + return nil, fmt.Errorf("codearts: authentication failed: %s", cbRes.Error) + } + if cbRes.Identifier == "" { + return nil, fmt.Errorf("codearts: missing identifier in callback") + } + + fmt.Println("Callback received, polling for login result...") + + pollCtx, pollCancel := context.WithTimeout(ctx, 2*time.Minute) + defer pollCancel() + + authResult, err := codeartsAuth.PollForLoginResult(pollCtx, ticketID, cbRes.Identifier) + if err != nil { + return nil, fmt.Errorf("codearts: %w", err) + } + + tokenData, err := codeartsAuth.ProcessLoginResult(ctx, authResult) + if err != nil { + return nil, fmt.Errorf("codearts: %w", err) + } + + label := tokenData.UserName + if label == "" { + label = "codearts" + } + + fmt.Println("CodeArts authentication successful") + + return &coreauth.Auth{ + ID: fmt.Sprintf("codearts-%s.json", tokenData.UserName), + Provider: "codearts", + FileName: fmt.Sprintf("codearts-%s.json", tokenData.UserName), + Label: label, + Metadata: map[string]any{ + "type": "codearts", + "ak": tokenData.AK, + "sk": tokenData.SK, + "security_token": tokenData.SecurityToken, + "x_auth_token": tokenData.XAuthToken, + "expires_at": tokenData.ExpiresAt.Format(time.RFC3339), + "user_id": tokenData.UserID, + "user_name": tokenData.UserName, + "domain_id": tokenData.DomainID, + "email": tokenData.Email, + }, + }, nil +} + +func generateCodeArtsTicketID() string { + b := make([]byte, 32) + rand.Read(b) + return fmt.Sprintf("%x", b) +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index dda0fae6f5..8659efe3a0 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -19,6 +19,7 @@ func init() { registerRefreshLead("codebuddy", func() Authenticator { return NewCodeBuddyAuthenticator() }) registerRefreshLead("cursor", func() Authenticator { return NewCursorAuthenticator() }) registerRefreshLead("qoder", func() Authenticator { return NewQoderAuthenticator() }) + registerRefreshLead("codearts", func() Authenticator { return NewCodeArtsAuthenticator() }) } func registerRefreshLead(provider string, factory func() Authenticator) {