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)
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"}},
+ },
+ }
+}