From 27ef6d6441ab4801f92b76beef0d5dcef7afba4c Mon Sep 17 00:00:00 2001 From: mcarter880 Date: Thu, 23 Apr 2026 10:26:09 +0000 Subject: [PATCH 1/2] feat: add CodeArts IDE provider with OAuth login, executor, and translator - Add internal/auth/codearts/ package (OAuth flow, HMAC signing, token models) - Add internal/runtime/executor/codearts_executor.go (full executor) - Add internal/translator/codearts/openai/ (request/response pass-through) - Register CodeArts in constant, service, translator init, oauth_model_alias - Add CodeArts OAuth Web handler with /v0/oauth/codearts/* routes - Add root /callback route for HuaweiCloud redirect - Register OAuth Web routes in server.go --- internal/api/server.go | 6 + internal/auth/codearts/codearts_auth.go | 295 +++++++++++ internal/auth/codearts/models.go | 24 + internal/auth/codearts/oauth_web.go | 388 ++++++++++++++ internal/auth/codearts/signer.go | 123 +++++ internal/constant/constant.go | 3 + .../runtime/executor/codearts_executor.go | 492 ++++++++++++++++++ .../codearts/openai/codearts_openai.go | 23 + .../openai/codearts_openai_request.go | 8 + internal/translator/codearts/openai/init.go | 19 + internal/translator/init.go | 2 + sdk/cliproxy/auth/oauth_model_alias.go | 2 +- sdk/cliproxy/service.go | 5 + 13 files changed, 1389 insertions(+), 1 deletion(-) create mode 100644 internal/auth/codearts/codearts_auth.go create mode 100644 internal/auth/codearts/models.go create mode 100644 internal/auth/codearts/oauth_web.go create mode 100644 internal/auth/codearts/signer.go create mode 100644 internal/runtime/executor/codearts_executor.go create mode 100644 internal/translator/codearts/openai/codearts_openai.go create mode 100644 internal/translator/codearts/openai/codearts_openai_request.go create mode 100644 internal/translator/codearts/openai/init.go diff --git a/internal/api/server.go b/internal/api/server.go index ee24aead23..27c3e84ad6 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -24,6 +24,7 @@ import ( "github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware" "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules" ampmodule "github.com/router-for-me/CLIProxyAPI/v6/internal/api/modules/amp" + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codearts" "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro" "github.com/router-for-me/CLIProxyAPI/v6/internal/cache" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" @@ -309,6 +310,11 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk kiroOAuthHandler.RegisterRoutes(engine) log.Info("Kiro OAuth Web routes registered at /v0/oauth/kiro/*") + // === CLIProxyAPIPlus 扩展: 注册 CodeArts OAuth Web 路由 === + codeArtsOAuthHandler := codearts.NewOAuthWebHandler(cfg) + codeArtsOAuthHandler.RegisterRoutes(engine) + log.Info("CodeArts OAuth Web routes registered at /v0/oauth/codearts/*") + if optionState.keepAliveEnabled { s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout) } diff --git a/internal/auth/codearts/codearts_auth.go b/internal/auth/codearts/codearts_auth.go new file mode 100644 index 0000000000..487e49ee93 --- /dev/null +++ b/internal/auth/codearts/codearts_auth.go @@ -0,0 +1,295 @@ +package codearts + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + log "github.com/sirupsen/logrus" +) + +const ( + IAMHost = "https://iam.cn-north-4.myhuaweicloud.com" + APIHost = "https://ide.cn-north-4.myhuaweicloud.com" + RedirectHost = "https://devcloud.cn-north-4.huaweicloud.com/codeartside" + ChatURL = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/chat/chat" + GptsURL = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/agent-center/agents" + + DefaultAgentID = "a8bcb36232554267a5142361cc25a393" + + tokenRefreshMargin = 4 * time.Hour +) + +// CodeArtsAuth manages the CodeArts authentication lifecycle. +type CodeArtsAuth struct { + httpClient *http.Client +} + +// NewCodeArtsAuth creates a new CodeArtsAuth instance. +func NewCodeArtsAuth(httpClient *http.Client) *CodeArtsAuth { + if httpClient == nil { + httpClient = &http.Client{Timeout: 30 * time.Second} + } + return &CodeArtsAuth{httpClient: httpClient} +} + +// AuthorizationURL returns the URL the user should visit to log in. +// Matches Python: build_login_url(ticket_id, port, theme=1, locale="zh-cn", version=3, uri_scheme="codearts") +func (a *CodeArtsAuth) AuthorizationURL(ticketID string, port int) string { + params := url.Values{} + params.Set("ticket_id", ticketID) + params.Set("theme", "1") + params.Set("locale", "zh-cn") + params.Set("version", "3") + params.Set("uri_scheme", "codearts") + params.Set("port", fmt.Sprintf("%d", port)) + params.Set("is_redirect", "true") + return fmt.Sprintf("%s/redirect1?%s", RedirectHost, params.Encode()) +} + +// PollForLoginResult polls the ticket endpoint until the user completes login. +// Matches Python: poll_login_ticket(ticket_id, identifier, timeout=120) +// Returns the full auth result JSON map. +func (a *CodeArtsAuth) PollForLoginResult(ctx context.Context, ticketID, identifier string) (map[string]interface{}, error) { + pollURL := fmt.Sprintf("%s/v2/login/ticket", APIHost) + + for i := 0; i < 60; i++ { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } + + payload, _ := json.Marshal(map[string]string{ + "ticket_id": ticketID, + "identifier": identifier, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", pollURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := a.httpClient.Do(req) + if err != nil { + log.Debugf("codearts: poll attempt %d failed: %v", i+1, err) + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + continue + } + + // Python checks: if data.get("status") == "success": return data.get("result") + if status, _ := result["status"].(string); status == "success" { + if authResult, ok := result["result"].(map[string]interface{}); ok { + log.Info("codearts: login successful") + return authResult, nil + } + } + + log.Debugf("codearts: poll attempt %d, status=%v", i+1, result["status"]) + } + return nil, fmt.Errorf("codearts: login timed out after 120s") +} + +// ExchangeForSecurityToken exchanges X-Auth-Token for AK/SK/SecurityToken. +// Matches Python: get_credential_by_token(x_auth_token) +func (a *CodeArtsAuth) ExchangeForSecurityToken(ctx context.Context, xAuthToken string) (*CodeArtsTokenData, error) { + exchangeURL := fmt.Sprintf("%s/v3.0/OS-CREDENTIAL/securitytokens", IAMHost) + + payload := map[string]interface{}{ + "auth": map[string]interface{}{ + "identity": map[string]interface{}{ + "methods": []string{"token"}, + "token": map[string]interface{}{ + "duration_seconds": 86400, + }, + }, + }, + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequestWithContext(ctx, "POST", exchangeURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json;charset=utf8") + req.Header.Set("X-Auth-Token", xAuthToken) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("codearts: security token exchange failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 201 { + return nil, fmt.Errorf("codearts: security token exchange returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Credential struct { + Access string `json:"access"` + Secret string `json:"secret"` + SecurityToken string `json:"securitytoken"` + ExpiresAt string `json:"expires_at"` + } `json:"credential"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("codearts: failed to parse security token response: %w", err) + } + + expiresAt, _ := time.Parse(time.RFC3339, result.Credential.ExpiresAt) + + return &CodeArtsTokenData{ + AK: result.Credential.Access, + SK: result.Credential.Secret, + SecurityToken: result.Credential.SecurityToken, + ExpiresAt: expiresAt, + XAuthToken: xAuthToken, + }, nil +} + +// ProcessLoginResult extracts credentials from login result. +// Matches Python logic: check for credential in result, or exchange x_auth_token. +func (a *CodeArtsAuth) ProcessLoginResult(ctx context.Context, authResult map[string]interface{}) (*CodeArtsTokenData, error) { + userID, _ := authResult["user_id"].(string) + userName, _ := authResult["user_name"].(string) + domainID, _ := authResult["domain_id"].(string) + + // Check if credential is directly in the result + var tokenData *CodeArtsTokenData + + if credMap, ok := authResult["credential"].(map[string]interface{}); ok { + // Credential directly in login result + ak, _ := credMap["access"].(string) + sk, _ := credMap["secret"].(string) + secToken, _ := credMap["securitytoken"].(string) + expiresAtStr, _ := credMap["expires_at"].(string) + expiresAt, _ := time.Parse(time.RFC3339, expiresAtStr) + + tokenData = &CodeArtsTokenData{ + AK: ak, + SK: sk, + SecurityToken: secToken, + ExpiresAt: expiresAt, + } + } else { + // Need to exchange x_auth_token for credential + xAuthToken, _ := authResult["x_auth_token"].(string) + if xAuthToken == "" { + xAuthToken, _ = authResult["token"].(string) + } + if xAuthToken == "" { + return nil, fmt.Errorf("codearts: no credential or x_auth_token in login result") + } + + log.Info("codearts: exchanging X-Auth-Token for AK/SK credentials") + var err error + tokenData, err = a.ExchangeForSecurityToken(ctx, xAuthToken) + if err != nil { + return nil, err + } + tokenData.XAuthToken = xAuthToken + } + + tokenData.UserID = userID + tokenData.UserName = userName + tokenData.DomainID = domainID + + return tokenData, nil +} + +// NeedsRefresh returns true if the token should be refreshed. +func NeedsRefresh(token *CodeArtsTokenData) bool { + if token == nil { + return true + } + return token.IsExpired(tokenRefreshMargin) +} + +// RefreshToken refreshes the security token using POST /v2/login/refresh. +// Matches Python: refresh_token(credential) +func (a *CodeArtsAuth) RefreshToken(ctx context.Context, token *CodeArtsTokenData) (*CodeArtsTokenData, error) { + if token == nil || (token.AK == "" || token.SK == "") { + return nil, fmt.Errorf("codearts: cannot refresh without AK/SK") + } + + refreshURL := fmt.Sprintf("%s/v2/login/refresh", APIHost) + body := []byte(`{"duration_seconds":86400}`) + + req, err := http.NewRequestWithContext(ctx, "POST", refreshURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Security-Token", token.SecurityToken) + req.Header.Set("Access-Key", token.AK) + + // Sign with SDK-HMAC-SHA256 + SignRequest(req, token.AK, token.SK, token.SecurityToken) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("codearts: refresh request failed: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + log.Warnf("codearts: refresh returned %d, attempting re-exchange", resp.StatusCode) + if token.XAuthToken != "" { + return a.ExchangeForSecurityToken(ctx, token.XAuthToken) + } + return nil, fmt.Errorf("codearts: refresh failed with status %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("codearts: failed to parse refresh response: %w", err) + } + + // Extract credential from response + credMap, ok := result["credential"].(map[string]interface{}) + if !ok { + if r, ok2 := result["result"].(map[string]interface{}); ok2 { + credMap, _ = r["credential"].(map[string]interface{}) + } + } + if credMap == nil { + credMap = result + } + + ak, _ := credMap["access"].(string) + sk, _ := credMap["secret"].(string) + secToken, _ := credMap["securitytoken"].(string) + expiresAtStr, _ := credMap["expires_at"].(string) + expiresAt, _ := time.Parse(time.RFC3339, expiresAtStr) + + if ak == "" || sk == "" { + return nil, fmt.Errorf("codearts: refresh response missing credentials") + } + + return &CodeArtsTokenData{ + AK: ak, + SK: sk, + SecurityToken: secToken, + ExpiresAt: expiresAt, + XAuthToken: token.XAuthToken, + UserID: token.UserID, + UserName: token.UserName, + DomainID: token.DomainID, + Email: token.Email, + }, nil +} diff --git a/internal/auth/codearts/models.go b/internal/auth/codearts/models.go new file mode 100644 index 0000000000..2500b2b72c --- /dev/null +++ b/internal/auth/codearts/models.go @@ -0,0 +1,24 @@ +package codearts + +import "time" + +// CodeArtsTokenData holds the authentication credentials. +type CodeArtsTokenData struct { + AK string `json:"access"` + SK string `json:"secret"` + SecurityToken string `json:"securitytoken"` + ExpiresAt time.Time `json:"expires_at"` + XAuthToken string `json:"x_auth_token,omitempty"` + Email string `json:"email,omitempty"` + UserID string `json:"user_id,omitempty"` + UserName string `json:"user_name,omitempty"` + DomainID string `json:"domain_id,omitempty"` +} + +// IsExpired returns true if the token is expired or will expire within margin. +func (t *CodeArtsTokenData) IsExpired(margin time.Duration) bool { + if t.ExpiresAt.IsZero() { + return true + } + return time.Now().Add(margin).After(t.ExpiresAt) +} diff --git a/internal/auth/codearts/oauth_web.go b/internal/auth/codearts/oauth_web.go new file mode 100644 index 0000000000..709458b885 --- /dev/null +++ b/internal/auth/codearts/oauth_web.go @@ -0,0 +1,388 @@ +package codearts + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + log "github.com/sirupsen/logrus" +) + +type sessionStatus string + +const ( + sPending sessionStatus = "pending" + sWaitingCB sessionStatus = "waiting_callback" + sPolling sessionStatus = "polling" + sSuccess sessionStatus = "success" + sFailed sessionStatus = "failed" +) + +type webSession struct { + stateID string + ticketID string + identifier string + status sessionStatus + startedAt time.Time + error string + token *CodeArtsTokenData + cancel context.CancelFunc +} + +// OAuthWebHandler handles CodeArts OAuth web login flow. +type OAuthWebHandler struct { + cfg *config.Config + sessions map[string]*webSession + // Map ticket_id -> stateID for callback lookup + ticketToState map[string]string + mu sync.RWMutex + auth *CodeArtsAuth +} + +// NewOAuthWebHandler creates a new CodeArts OAuth web handler. +func NewOAuthWebHandler(cfg *config.Config) *OAuthWebHandler { + return &OAuthWebHandler{ + cfg: cfg, + sessions: make(map[string]*webSession), + ticketToState: make(map[string]string), + auth: NewCodeArtsAuth(nil), + } +} + +// RegisterRoutes registers CodeArts OAuth web routes. +func (h *OAuthWebHandler) RegisterRoutes(router gin.IRouter) { + oauth := router.Group("/v0/oauth/codearts") + { + oauth.GET("", h.handleIndex) + oauth.GET("/start", h.handleStart) + oauth.GET("/callback", h.handleCallback) + oauth.GET("/status", h.handleStatus) + } + // Root-level callback: HuaweiCloud redirects to http://127.0.0.1:{port}/callback + router.GET("/callback", h.handleCallback) +} + +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func generateTicketID() string { + b := make([]byte, 32) + rand.Read(b) + return fmt.Sprintf("%x", b) +} + +func (h *OAuthWebHandler) handleIndex(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, codeArtsLoginPage) +} + +func (h *OAuthWebHandler) handleStart(c *gin.Context) { + stateID, err := generateState() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate state"}) + return + } + + // Generate ticket_id matching Python: secrets.token_hex(32) = 64 hex chars + ticketID := generateTicketID() + + port := h.cfg.Port + if port == 0 { + port = 8318 + } + + sess := &webSession{ + stateID: stateID, + ticketID: ticketID, + status: sWaitingCB, + startedAt: time.Now(), + } + + h.mu.Lock() + h.sessions[stateID] = sess + h.ticketToState[ticketID] = stateID + h.mu.Unlock() + + // Build login URL matching Python: {REDIRECT_HOST}/redirect1?ticket_id=...&theme=1&... + loginURL := h.auth.AuthorizationURL(ticketID, port) + + log.Infof("CodeArts OAuth: session %s started, login URL: %s", stateID, loginURL) + + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, fmt.Sprintf(codeArtsWaitingPage, loginURL, stateID)) +} + +// handleCallback receives the callback from HuaweiCloud after user login. +// Python: GET /callback?identifier=XXX&redirect=YYY +// The redirect URL contains ticket_id which we use to match the correct session. +func (h *OAuthWebHandler) handleCallback(c *gin.Context) { + identifier := c.Query("identifier") + redirectURL := c.Query("redirect") + + log.Infof("CodeArts OAuth: callback received, identifier=%s, redirect=%s", identifier, redirectURL) + + if identifier == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "lack argument identifier"}) + return + } + + // Extract ticket_id from redirect URL to match the correct session + var ticketFromRedirect string + if redirectURL != "" { + if parsed, err := url.Parse(redirectURL); err == nil { + ticketFromRedirect = parsed.Query().Get("ticket_id") + } + } + + h.mu.Lock() + var matchedSess *webSession + + // First try: match by ticket_id from redirect URL + if ticketFromRedirect != "" { + if stateID, ok := h.ticketToState[ticketFromRedirect]; ok { + if sess, ok2 := h.sessions[stateID]; ok2 { + sess.identifier = identifier + sess.status = sPolling + matchedSess = sess + log.Infof("CodeArts OAuth: matched session by ticket_id=%s", ticketFromRedirect) + } + } + } + + // Fallback: match the most recent waiting session + if matchedSess == nil { + var latestSess *webSession + for _, sess := range h.sessions { + if sess.status == sWaitingCB { + if latestSess == nil || sess.startedAt.After(latestSess.startedAt) { + latestSess = sess + } + } + } + if latestSess != nil { + latestSess.identifier = identifier + latestSess.status = sPolling + matchedSess = latestSess + log.Infof("CodeArts OAuth: matched session by fallback (latest waiting), ticket=%s", latestSess.ticketID) + } + } + h.mu.Unlock() + + if matchedSess != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + matchedSess.cancel = cancel + go h.pollLogin(ctx, matchedSess) + } else { + log.Warn("CodeArts OAuth: no matching session found for callback") + } + + if redirectURL != "" { + c.Redirect(http.StatusTemporaryRedirect, redirectURL) + } else { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, `

Login callback received! You can close this tab.

`) + } +} + +func (h *OAuthWebHandler) pollLogin(ctx context.Context, sess *webSession) { + if sess.cancel != nil { + defer sess.cancel() + } + + log.Infof("CodeArts OAuth: polling for login result, ticket=%s, identifier=%s", sess.ticketID, sess.identifier) + + // Poll with ticket_id + identifier (matching Python: poll_login_ticket) + authResult, err := h.auth.PollForLoginResult(ctx, sess.ticketID, sess.identifier) + if err != nil { + h.mu.Lock() + sess.status = sFailed + sess.error = err.Error() + h.mu.Unlock() + log.Errorf("CodeArts OAuth: poll failed: %v", err) + return + } + + // Process login result: extract credential or exchange x_auth_token + tokenData, err := h.auth.ProcessLoginResult(ctx, authResult) + if err != nil { + h.mu.Lock() + sess.status = sFailed + sess.error = err.Error() + h.mu.Unlock() + log.Errorf("CodeArts OAuth: process result failed: %v", err) + return + } + + h.mu.Lock() + sess.status = sSuccess + sess.token = tokenData + h.mu.Unlock() + + // Save auth file + h.saveTokenToFile(tokenData) + log.Infof("CodeArts OAuth: authentication successful for user %s", tokenData.UserName) +} + +func (h *OAuthWebHandler) handleStatus(c *gin.Context) { + stateID := c.Query("state") + if stateID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing state"}) + return + } + + h.mu.RLock() + sess, ok := h.sessions[stateID] + h.mu.RUnlock() + + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "session not found"}) + return + } + + switch sess.status { + case sSuccess: + msg := "Login successful! Token saved." + if sess.token != nil && sess.token.UserName != "" { + msg = fmt.Sprintf("Login successful! User: %s", sess.token.UserName) + } + c.JSON(http.StatusOK, gin.H{"status": "success", "message": msg}) + case sFailed: + c.JSON(http.StatusOK, gin.H{"status": "failed", "error": sess.error}) + case sPolling: + c.JSON(http.StatusOK, gin.H{"status": "pending", "message": "Polling for login result..."}) + default: + c.JSON(http.StatusOK, gin.H{"status": "pending", "message": "Waiting for browser callback..."}) + } +} + +func (h *OAuthWebHandler) saveTokenToFile(tokenData *CodeArtsTokenData) { + authDir := "" + if h.cfg != nil && h.cfg.AuthDir != "" { + var err error + authDir, err = util.ResolveAuthDir(h.cfg.AuthDir) + if err != nil { + log.Errorf("CodeArts OAuth: failed to resolve auth directory: %v", err) + } + } + if authDir == "" { + home, err := os.UserHomeDir() + if err != nil { + log.Errorf("CodeArts OAuth: failed to get home directory: %v", err) + return + } + authDir = filepath.Join(home, ".cli-proxy-api") + } + if err := os.MkdirAll(authDir, 0700); err != nil { + log.Errorf("CodeArts OAuth: failed to create auth directory: %v", err) + return + } + + fileName := "codearts-token.json" + if tokenData.UserName != "" { + fileName = fmt.Sprintf("codearts-%s.json", tokenData.UserName) + } + + // 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), + } + + data, err := json.MarshalIndent(storage, "", " ") + if err != nil { + log.Errorf("CodeArts OAuth: failed to marshal token: %v", err) + return + } + + authFilePath := filepath.Join(authDir, fileName) + if err := os.WriteFile(authFilePath, data, 0600); err != nil { + log.Errorf("CodeArts OAuth: failed to write auth file: %v", err) + return + } + log.Infof("CodeArts OAuth: token saved to %s", authFilePath) +} + +// HTML templates +const codeArtsLoginPage = ` +CodeArts IDE Login + +
+

🔑 CodeArts IDE Login

+

Login with your HuaweiCloud account to use CodeArts IDE models through CLIProxyAPI.

+Start Login +
` + +const codeArtsWaitingPage = ` +CodeArts IDE Login - Waiting + +
+

🔑 CodeArts IDE Login

+

Click the button below to open HuaweiCloud login page. After login, you will be redirected back here.

+Open HuaweiCloud Login +
⏳ Waiting for login callback...
+
+` diff --git a/internal/auth/codearts/signer.go b/internal/auth/codearts/signer.go new file mode 100644 index 0000000000..24c82421c4 --- /dev/null +++ b/internal/auth/codearts/signer.go @@ -0,0 +1,123 @@ +package codearts + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +// SignRequest signs an HTTP request using SDK-HMAC-SHA256. +// This is HuaweiCloud's signing algorithm (NOT AWS SigV4). +// Key differences from AWS SigV4: +// - 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) { + now := time.Now().UTC() + timeStr := now.Format("20060102T150405Z") + + req.Header.Set("X-Sdk-Date", timeStr) + if securityToken != "" { + req.Header.Set("X-Security-Token", securityToken) + } + + // Canonical request + method := req.Method + path := req.URL.Path + if path == "" { + path = "/" + } + if !strings.HasSuffix(path, "/") { + path += "/" + } + + // Canonical query string + canonicalQuery := canonicalQueryString(req.URL.Query()) + + // Signed headers + signedHeaderKeys := []string{"host", "x-sdk-date"} + if securityToken != "" { + signedHeaderKeys = append(signedHeaderKeys, "x-security-token") + } + // Add content-type if present + if ct := req.Header.Get("Content-Type"); ct != "" { + signedHeaderKeys = append(signedHeaderKeys, "content-type") + } + sort.Strings(signedHeaderKeys) + + // Canonical headers + var canonicalHeaders strings.Builder + for _, key := range signedHeaderKeys { + var val string + if key == "host" { + val = req.Host + if val == "" { + val = req.URL.Host + } + } else { + val = req.Header.Get(key) + } + canonicalHeaders.WriteString(strings.ToLower(key)) + canonicalHeaders.WriteString(":") + canonicalHeaders.WriteString(strings.TrimSpace(val)) + canonicalHeaders.WriteString("\n") + } + + signedHeadersStr := strings.Join(signedHeaderKeys, ";") + + // Body hash (empty for GET, or use existing hash) + bodyHash := sha256Hex([]byte("")) + + canonicalReq := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + method, path, canonicalQuery, + canonicalHeaders.String(), signedHeadersStr, bodyHash) + + // String to sign + stringToSign := fmt.Sprintf("SDK-HMAC-SHA256\n%s\n%s", + timeStr, sha256Hex([]byte(canonicalReq))) + + // Signature (single-step HMAC, not derived key) + signature := hmacSHA256Hex([]byte(sk), []byte(stringToSign)) + + // Authorization header + authHeader := fmt.Sprintf("SDK-HMAC-SHA256 Access=%s, SignedHeaders=%s, Signature=%s", + ak, signedHeadersStr, signature) + req.Header.Set("Authorization", authHeader) +} + +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func hmacSHA256Hex(key, data []byte) string { + h := hmac.New(sha256.New, key) + h.Write(data) + return hex.EncodeToString(h.Sum(nil)) +} + +func canonicalQueryString(query url.Values) string { + if len(query) == 0 { + return "" + } + keys := make([]string, 0, len(query)) + for k := range query { + keys = append(keys, k) + } + sort.Strings(keys) + var parts []string + for _, k := range keys { + vals := query[k] + sort.Strings(vals) + for _, v := range vals { + parts = append(parts, url.QueryEscape(k)+"="+url.QueryEscape(v)) + } + } + return strings.Join(parts, "&") +} diff --git a/internal/constant/constant.go b/internal/constant/constant.go index 9b7d31aab6..2795c577b5 100644 --- a/internal/constant/constant.go +++ b/internal/constant/constant.go @@ -30,4 +30,7 @@ const ( // Kilo represents the Kilo AI provider identifier. Kilo = "kilo" + + // CodeArts represents the HuaweiCloud CodeArts IDE provider identifier. + CodeArts = "codearts" ) diff --git a/internal/runtime/executor/codearts_executor.go b/internal/runtime/executor/codearts_executor.go new file mode 100644 index 0000000000..db0e6d5f66 --- /dev/null +++ b/internal/runtime/executor/codearts_executor.go @@ -0,0 +1,492 @@ +package executor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + 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" + "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" + cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" +) + +const ( + codeartsChatURL = "https://snap-access.cn-north-4.myhuaweicloud.com/v1/chat/chat" + codeArtsUserAgent = "DevKit-VSCode:huaweicloud.codearts-snap|CodeArts Agent:D1" +) + +// CodeArtsExecutor executes chat completions against the HuaweiCloud CodeArts API. +type CodeArtsExecutor struct { + cfg *config.Config +} + +// NewCodeArtsExecutor constructs a new executor instance. +func NewCodeArtsExecutor(cfg *config.Config) *CodeArtsExecutor { + return &CodeArtsExecutor{cfg: cfg} +} + +// Identifier returns the executor's provider key. +func (e *CodeArtsExecutor) Identifier() string { return "codearts" } + +// PrepareRequest sets CodeArts-specific headers and signs the request. +func (e *CodeArtsExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if auth == nil || auth.Metadata == nil { + return fmt.Errorf("codearts: missing auth metadata") + } + + ak, _ := auth.Metadata["ak"].(string) + sk, _ := auth.Metadata["sk"].(string) + securityToken, _ := auth.Metadata["security_token"].(string) + + if ak == "" || sk == "" { + return fmt.Errorf("codearts: missing AK/SK credentials") + } + + req.Header.Set("User-Agent", codeArtsUserAgent) + req.Header.Set("Agent-Type", "ChatAgent") + req.Header.Set("Client-Version", "Vscode_26.3.5") + req.Header.Set("Plugin-Name", "snap_vscode") + req.Header.Set("Plugin-Version", "26.3.5") + req.Header.Set("Content-Type", "application/json") + + codearts.SignRequest(req, ak, sk, securityToken) + return nil +} + +// HttpRequest executes a signed HTTP request to CodeArts. +func (e *CodeArtsExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + client := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 5*time.Minute) + + if err := e.PrepareRequest(req, auth); err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("codearts: request failed: %w", err) + } + return resp, nil +} + +// Execute handles non-streaming chat completions. +func (e *CodeArtsExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + parsed := thinking.ParseSuffix(req.Model) + baseModel := parsed.ModelName + + reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth) + defer reporter.TrackFailure(ctx, &err) + + agentID := codearts.DefaultAgentID + if auth.Attributes != nil { + if aid := strings.TrimSpace(auth.Attributes["agent_id"]); aid != "" { + agentID = aid + } + } + + payload := buildCodeArtsPayload(req.Payload, baseModel, agentID, opts) + + httpReq, err := http.NewRequestWithContext(ctx, "POST", codeartsChatURL, bytes.NewReader(payload)) + if err != nil { + return resp, err + } + + httpResp, err := e.HttpRequest(ctx, auth, httpReq) + if err != nil { + return resp, err + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != 200 { + body, _ := io.ReadAll(httpResp.Body) + return resp, statusErr{ + code: httpResp.StatusCode, + msg: fmt.Sprintf("codearts: API returned %d: %s", httpResp.StatusCode, string(body)), + } + } + + var contentBuilder strings.Builder + var reasoningBuilder strings.Builder + var promptTokens, completionTokens int64 + + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, ":heartbeat") || line == "" { + continue + } + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + delta := gjson.Get(data, "delta") + if delta.Exists() { + if c := delta.Get("content").String(); c != "" { + contentBuilder.WriteString(c) + } + if r := delta.Get("reasoning_content").String(); r != "" { + reasoningBuilder.WriteString(r) + } + } + if pt := gjson.Get(data, "prompt_tokens").Int(); pt > 0 { + promptTokens = pt + } + if ct := gjson.Get(data, "completion_tokens").Int(); ct > 0 { + completionTokens = ct + } + } + + from := sdktranslator.FromString("openai") + to := sdktranslator.FromString("codearts") + + openAIResp := buildOpenAINonStreamResponse(contentBuilder.String(), reasoningBuilder.String(), req.Model, promptTokens, completionTokens) + var param any + translated := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, req.Payload, openAIResp, ¶m) + + reporter.Publish(ctx, usage.Detail{ + InputTokens: promptTokens, + OutputTokens: completionTokens, + }) + + helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ + URL: codeartsChatURL, + Method: "POST", + Provider: "codearts", + AuthID: auth.ID, + }) + + return cliproxyexecutor.Response{Payload: translated}, nil +} + +// ExecuteStream handles streaming chat completions. +func (e *CodeArtsExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + parsed := thinking.ParseSuffix(req.Model) + baseModel := parsed.ModelName + + reporter := helps.NewUsageReporter(ctx, e.Identifier(), baseModel, auth) + defer reporter.TrackFailure(ctx, &err) + + agentID := codearts.DefaultAgentID + if auth.Attributes != nil { + if aid := strings.TrimSpace(auth.Attributes["agent_id"]); aid != "" { + agentID = aid + } + } + + payload := buildCodeArtsPayload(req.Payload, baseModel, agentID, opts) + + httpReq, err := http.NewRequestWithContext(ctx, "POST", codeartsChatURL, bytes.NewReader(payload)) + if err != nil { + return nil, err + } + + httpResp, err := e.HttpRequest(ctx, auth, httpReq) + if err != nil { + return nil, err + } + + if httpResp.StatusCode != 200 { + body, _ := io.ReadAll(httpResp.Body) + httpResp.Body.Close() + return nil, statusErr{ + code: httpResp.StatusCode, + msg: fmt.Sprintf("codearts: API returned %d: %s", httpResp.StatusCode, string(body)), + } + } + + chunks := make(chan cliproxyexecutor.StreamChunk, 64) + + go func() { + defer close(chunks) + defer httpResp.Body.Close() + + from := sdktranslator.FromString("openai") + to := sdktranslator.FromString("codearts") + var streamParam any + var totalPromptTokens, totalCompletionTokens int64 + + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, ":heartbeat") || line == "" { + continue + } + if !strings.HasPrefix(line, "data: ") { + continue + } + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + openAIChunk := convertCodeArtsSSEToOpenAI(data, req.Model) + if openAIChunk == nil { + continue + } + + if pt := gjson.Get(data, "prompt_tokens").Int(); pt > 0 { + totalPromptTokens = pt + } + if ct := gjson.Get(data, "completion_tokens").Int(); ct > 0 { + totalCompletionTokens = ct + } + + translatedChunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, req.Payload, openAIChunk, &streamParam) + for _, tc := range translatedChunks { + if len(tc) > 0 { + chunks <- cliproxyexecutor.StreamChunk{Payload: tc} + } + } + } + + if err := scanner.Err(); err != nil { + log.Warnf("codearts: stream scanner error: %v", err) + chunks <- cliproxyexecutor.StreamChunk{Err: err} + } + + reporter.Publish(ctx, usage.Detail{ + InputTokens: totalPromptTokens, + OutputTokens: totalCompletionTokens, + }) + + helps.RecordAPIRequest(ctx, e.cfg, helps.UpstreamRequestLog{ + URL: codeartsChatURL, + Method: "POST", + Provider: "codearts", + AuthID: auth.ID, + }) + }() + + return &cliproxyexecutor.StreamResult{ + Headers: httpResp.Header, + Chunks: chunks, + }, nil +} + +// CountTokens is not supported by CodeArts. +func (e *CodeArtsExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, fmt.Errorf("codearts: token counting not supported") +} + +// Refresh refreshes the CodeArts security token. +func (e *CodeArtsExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil || auth.Metadata == nil { + return nil, fmt.Errorf("codearts: no metadata to refresh") + } + + currentToken := extractCodeArtsToken(auth) + if currentToken == nil { + return nil, fmt.Errorf("codearts: no valid token data found for refresh") + } + + if !codearts.NeedsRefresh(currentToken) { + return auth, nil + } + + caAuth := codearts.NewCodeArtsAuth(nil) + newToken, err := caAuth.RefreshToken(ctx, currentToken) + if err != nil { + return nil, fmt.Errorf("codearts: refresh failed: %w", err) + } + + updated := auth.Clone() + updated.Metadata["ak"] = newToken.AK + updated.Metadata["sk"] = newToken.SK + updated.Metadata["security_token"] = newToken.SecurityToken + updated.Metadata["expires_at"] = newToken.ExpiresAt.Format(time.RFC3339) + if newToken.XAuthToken != "" { + updated.Metadata["x_auth_token"] = newToken.XAuthToken + } + + log.Infof("codearts: successfully refreshed token, expires at %s", newToken.ExpiresAt.Format(time.RFC3339)) + return updated, nil +} + +// extractCodeArtsToken extracts token data from auth metadata. +func extractCodeArtsToken(auth *cliproxyauth.Auth) *codearts.CodeArtsTokenData { + if auth == nil || auth.Metadata == nil { + return nil + } + + ak, _ := auth.Metadata["ak"].(string) + sk, _ := auth.Metadata["sk"].(string) + if ak == "" || sk == "" { + return nil + } + + token := &codearts.CodeArtsTokenData{ + AK: ak, + SK: sk, + SecurityToken: metadataStr(auth.Metadata, "security_token"), + XAuthToken: metadataStr(auth.Metadata, "x_auth_token"), + Email: metadataStr(auth.Metadata, "email"), + } + + if expiresStr := metadataStr(auth.Metadata, "expires_at"); expiresStr != "" { + if t, err := time.Parse(time.RFC3339, expiresStr); err == nil { + token.ExpiresAt = t + } + } + + return token +} + +func metadataStr(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +// buildCodeArtsPayload converts the OpenAI-format payload to CodeArts format. +func buildCodeArtsPayload(openaiPayload []byte, modelName, agentID string, opts cliproxyexecutor.Options) []byte { + messages := gjson.GetBytes(openaiPayload, "messages") + if !messages.Exists() { + log.Warn("codearts: no messages found in payload") + return openaiPayload + } + + var codeArtsMessages []map[string]string + for _, msg := range messages.Array() { + role := msg.Get("role").String() + content := msg.Get("content").String() + + var formattedContent string + switch role { + case "system": + formattedContent = "[System]\n" + content + case "assistant": + formattedContent = "[Assistant]\n" + content + case "user": + formattedContent = content + case "tool": + toolName := msg.Get("name").String() + if toolName == "" { + toolName = "unknown" + } + formattedContent = fmt.Sprintf("[Tool Result: %s]\n%s", toolName, content) + default: + formattedContent = content + } + + codeArtsMessages = append(codeArtsMessages, map[string]string{ + "role": role, + "content": formattedContent, + }) + } + + request := map[string]interface{}{ + "messages": codeArtsMessages, + "model": modelName, + "agent_id": agentID, + "stream": true, + } + + if maxTokens := gjson.GetBytes(openaiPayload, "max_tokens"); maxTokens.Exists() { + request["max_tokens"] = maxTokens.Value() + } + if temp := gjson.GetBytes(openaiPayload, "temperature"); temp.Exists() { + request["temperature"] = temp.Value() + } + + result, err := json.Marshal(request) + if err != nil { + log.Errorf("codearts: failed to marshal payload: %v", err) + return openaiPayload + } + return result +} + +// convertCodeArtsSSEToOpenAI converts a CodeArts SSE data line to OpenAI SSE format. +func convertCodeArtsSSEToOpenAI(data string, model string) []byte { + delta := gjson.Get(data, "delta") + if !delta.Exists() { + return nil + } + + content := delta.Get("content").String() + reasoningContent := delta.Get("reasoning_content").String() + + if content == "" && reasoningContent == "" { + return nil + } + + deltaMap := make(map[string]interface{}) + if content != "" { + deltaMap["content"] = content + } + if reasoningContent != "" { + deltaMap["reasoning_content"] = reasoningContent + } + + chunk := map[string]interface{}{ + "id": "chatcmpl-codearts", + "object": "chat.completion.chunk", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": deltaMap, + }, + }, + } + + result, err := json.Marshal(chunk) + if err != nil { + return nil + } + + return append([]byte("data: "), result...) +} + +// buildOpenAINonStreamResponse builds a complete OpenAI non-stream response. +func buildOpenAINonStreamResponse(content, reasoning, model string, promptTokens, completionTokens int64) []byte { + message := map[string]interface{}{ + "role": "assistant", + "content": content, + } + if reasoning != "" { + message["reasoning_content"] = reasoning + } + + resp := map[string]interface{}{ + "id": "chatcmpl-codearts", + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{ + { + "index": 0, + "finish_reason": "stop", + "message": message, + }, + }, + "usage": map[string]interface{}{ + "prompt_tokens": promptTokens, + "completion_tokens": completionTokens, + "total_tokens": promptTokens + completionTokens, + }, + } + + result, _ := json.Marshal(resp) + return result +} diff --git a/internal/translator/codearts/openai/codearts_openai.go b/internal/translator/codearts/openai/codearts_openai.go new file mode 100644 index 0000000000..46cb4fed19 --- /dev/null +++ b/internal/translator/codearts/openai/codearts_openai.go @@ -0,0 +1,23 @@ +package openai + +import ( + "context" +) + +// ConvertCodeArtsStreamToOpenAI passes through SSE chunks. +// The executor already converts CodeArts SSE to OpenAI SSE format. +func ConvertCodeArtsStreamToOpenAI(ctx context.Context, model string, originalRequest, translatedRequest, chunk []byte, state *any) [][]byte { + if len(chunk) == 0 { + return nil + } + return [][]byte{chunk} +} + +// ConvertCodeArtsNonStreamToOpenAI passes through non-stream responses. +// The executor already builds OpenAI-format responses. +func ConvertCodeArtsNonStreamToOpenAI(ctx context.Context, model string, originalRequest, translatedRequest, response []byte, param *any) []byte { + if len(response) == 0 { + return nil + } + return response +} diff --git a/internal/translator/codearts/openai/codearts_openai_request.go b/internal/translator/codearts/openai/codearts_openai_request.go new file mode 100644 index 0000000000..d820c1abb5 --- /dev/null +++ b/internal/translator/codearts/openai/codearts_openai_request.go @@ -0,0 +1,8 @@ +package openai + +// ConvertOpenAIRequestToCodeArts passes through the OpenAI-format request payload. +// Actual conversion to CodeArts format happens in the executor (buildCodeArtsPayload), +// following the same pattern as Kiro's translator. +func ConvertOpenAIRequestToCodeArts(model string, rawJSON []byte, stream bool) []byte { + return rawJSON +} diff --git a/internal/translator/codearts/openai/init.go b/internal/translator/codearts/openai/init.go new file mode 100644 index 0000000000..a2273f78e0 --- /dev/null +++ b/internal/translator/codearts/openai/init.go @@ -0,0 +1,19 @@ +package openai + +import ( + . "github.com/router-for-me/CLIProxyAPI/v6/internal/constant" + "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces" + "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator" +) + +func init() { + translator.Register( + OpenAI, + CodeArts, + ConvertOpenAIRequestToCodeArts, + interfaces.TranslateResponse{ + Stream: ConvertCodeArtsStreamToOpenAI, + NonStream: ConvertCodeArtsNonStreamToOpenAI, + }, + ) +} diff --git a/internal/translator/init.go b/internal/translator/init.go index 0754db03b4..ecc7c7dc7d 100644 --- a/internal/translator/init.go +++ b/internal/translator/init.go @@ -36,4 +36,6 @@ import ( _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/claude" _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/openai" + + _ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/codearts/openai" ) diff --git a/sdk/cliproxy/auth/oauth_model_alias.go b/sdk/cliproxy/auth/oauth_model_alias.go index 951cdecf07..a4592b8f66 100644 --- a/sdk/cliproxy/auth/oauth_model_alias.go +++ b/sdk/cliproxy/auth/oauth_model_alias.go @@ -289,7 +289,7 @@ func OAuthModelAliasChannel(provider, authKind string) string { return "" } return "codex" - case "gemini-cli", "aistudio", "antigravity", "iflow", "kiro", "github-copilot", "kimi": + case "gemini-cli", "aistudio", "antigravity", "iflow", "kiro", "github-copilot", "kimi", "codearts": return provider default: return "" diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 0079df73ee..b85e0462b2 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -448,6 +448,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewCodeBuddyExecutor(s.cfg)) case "codebuddy-ai": s.coreManager.RegisterExecutor(executor.NewCodeBuddyAIExecutor(s.cfg)) + case "codearts": + s.coreManager.RegisterExecutor(executor.NewCodeArtsExecutor(s.cfg)) case "gitlab": s.coreManager.RegisterExecutor(executor.NewGitLabExecutor(s.cfg)) default: @@ -981,6 +983,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "kilo": models = executor.FetchKiloModels(context.Background(), a, s.cfg) models = applyExcludedModels(models, excluded) + case "codearts": + models = getCodeArtsModels() + models = applyExcludedModels(models, excluded) case "gitlab": models = executor.GitLabModelsFromAuth(a) models = applyExcludedModels(models, excluded) From d86545a83c90e1e414c6a61aad0845ebcdd4c020 Mon Sep 17 00:00:00 2001 From: mcarter880 Date: Thu, 23 Apr 2026 10:30:14 +0000 Subject: [PATCH 2/2] fix: add missing service_codearts.go (getCodeArtsModels) --- sdk/cliproxy/service_codearts.go | 59 ++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 sdk/cliproxy/service_codearts.go diff --git a/sdk/cliproxy/service_codearts.go b/sdk/cliproxy/service_codearts.go new file mode 100644 index 0000000000..74e8814e95 --- /dev/null +++ b/sdk/cliproxy/service_codearts.go @@ -0,0 +1,59 @@ +package cliproxy + +import ( + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" +) + +// getCodeArtsModels returns the hardcoded list of CodeArts models. +func getCodeArtsModels() []*ModelInfo { + now := time.Now().Unix() + return []*ModelInfo{ + { + ID: "Glm-5-internal", + Object: "model", + Created: now, + OwnedBy: "huaweicloud", + Type: "codearts", + DisplayName: "GLM-5 Internal", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, + }, + { + ID: "GLM-5.1", + Object: "model", + Created: now, + OwnedBy: "huaweicloud", + Type: "codearts", + DisplayName: "GLM-5.1", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, + }, + { + ID: "deepseek-v3.2", + Object: "model", + Created: now, + OwnedBy: "huaweicloud", + Type: "codearts", + DisplayName: "DeepSeek V3.2", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, + }, + { + ID: "Glm-4.7-internal", + Object: "model", + Created: now, + OwnedBy: "huaweicloud", + Type: "codearts", + DisplayName: "GLM-4.7 Internal", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, + }, + { + ID: "GLM-4.7-SFT-Harmony", + Object: "model", + Created: now, + OwnedBy: "huaweicloud", + Type: "codearts", + DisplayName: "GLM-4.7 SFT Harmony", + Thinking: ®istry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}, + }, + } +}