From 4022e6965163b085f8a81b53639c43c0eb1a9795 Mon Sep 17 00:00:00 2001 From: Wei Lee Date: Wed, 18 Mar 2026 17:50:12 +0800 Subject: [PATCH 1/3] feat(auth): add CodeBuddy-CN browser OAuth authentication support --- cmd/server/main.go | 5 + go.mod | 2 +- internal/auth/codebuddy/codebuddy_auth.go | 335 +++++++++++++++++ .../codebuddy/codebuddy_auth_http_test.go | 285 +++++++++++++++ .../auth/codebuddy/codebuddy_auth_test.go | 22 ++ internal/auth/codebuddy/errors.go | 25 ++ internal/auth/codebuddy/token.go | 65 ++++ internal/cmd/auth_manager.go | 4 +- internal/cmd/codebuddy_login.go | 43 +++ internal/registry/model_definitions.go | 84 +++++ .../runtime/executor/codebuddy_executor.go | 343 ++++++++++++++++++ sdk/auth/codebuddy.go | 95 +++++ sdk/auth/refresh_registry.go | 1 + sdk/cliproxy/service.go | 5 + 14 files changed, 1311 insertions(+), 3 deletions(-) create mode 100644 internal/auth/codebuddy/codebuddy_auth.go create mode 100644 internal/auth/codebuddy/codebuddy_auth_http_test.go create mode 100644 internal/auth/codebuddy/codebuddy_auth_test.go create mode 100644 internal/auth/codebuddy/errors.go create mode 100644 internal/auth/codebuddy/token.go create mode 100644 internal/cmd/codebuddy_login.go create mode 100644 internal/runtime/executor/codebuddy_executor.go create mode 100644 sdk/auth/codebuddy.go diff --git a/cmd/server/main.go b/cmd/server/main.go index f66c12ee39..e86c51b850 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -95,6 +95,7 @@ func main() { var kiroIDCRegion string var kiroIDCFlow string var githubCopilotLogin bool + var codeBuddyLogin bool var projectID string var vertexImport string var configPath string @@ -131,6 +132,7 @@ func main() { flag.StringVar(&kiroIDCRegion, "kiro-idc-region", "", "IDC region (default: us-east-1)") flag.StringVar(&kiroIDCFlow, "kiro-idc-flow", "", "IDC flow type: authcode (default) or device") flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow") + flag.BoolVar(&codeBuddyLogin, "codebuddy-login", false, "Login to CodeBuddy using browser OAuth flow") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") flag.StringVar(&vertexImport, "vertex-import", "", "Import Vertex service account key JSON file") @@ -514,6 +516,9 @@ func main() { } else if githubCopilotLogin { // Handle GitHub Copilot login cmd.DoGitHubCopilotLogin(cfg, options) + } else if codeBuddyLogin { + // Handle CodeBuddy login + cmd.DoCodeBuddyLogin(cfg, options) } else if codexLogin { // Handle Codex login cmd.DoCodexLogin(cfg, options) diff --git a/go.mod b/go.mod index 461d5517d7..eb651d46cd 100644 --- a/go.mod +++ b/go.mod @@ -91,8 +91,8 @@ require ( github.com/tidwall/pretty v1.2.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/internal/auth/codebuddy/codebuddy_auth.go b/internal/auth/codebuddy/codebuddy_auth.go new file mode 100644 index 0000000000..9982c0fc04 --- /dev/null +++ b/internal/auth/codebuddy/codebuddy_auth.go @@ -0,0 +1,335 @@ +package codebuddy + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + log "github.com/sirupsen/logrus" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" +) + +const ( + BaseURL = "https://copilot.tencent.com" + DefaultDomain = "www.codebuddy.cn" + UserAgent = "CLI/2.63.2 CodeBuddy/2.63.2" + + codeBuddyStatePath = "/v2/plugin/auth/state" + codeBuddyTokenPath = "/v2/plugin/auth/token" + codeBuddyRefreshPath = "/v2/plugin/auth/token/refresh" + pollInterval = 5 * time.Second + maxPollDuration = 5 * time.Minute + codeLoginPending = 11217 + codeSuccess = 0 +) + +type CodeBuddyAuth struct { + httpClient *http.Client + cfg *config.Config + baseURL string +} + +func NewCodeBuddyAuth(cfg *config.Config) *CodeBuddyAuth { + httpClient := &http.Client{Timeout: 30 * time.Second} + if cfg != nil { + httpClient = util.SetProxy(&cfg.SDKConfig, httpClient) + } + return &CodeBuddyAuth{httpClient: httpClient, cfg: cfg, baseURL: BaseURL} +} + +// AuthState holds the state and auth URL returned by the auth state API. +type AuthState struct { + State string + AuthURL string +} + +// FetchAuthState calls POST /v2/plugin/auth/state?platform=CLI to get the state and login URL. +func (a *CodeBuddyAuth) FetchAuthState(ctx context.Context) (*AuthState, error) { + stateURL := fmt.Sprintf("%s%s?platform=CLI", a.baseURL, codeBuddyStatePath) + body := []byte("{}") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, stateURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("codebuddy: failed to create auth state request: %w", err) + } + + requestID := strings.ReplaceAll(uuid.New().String(), "-", "") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("X-Domain", "copilot.tencent.com") + req.Header.Set("X-No-Authorization", "true") + req.Header.Set("X-No-User-Id", "true") + req.Header.Set("X-No-Enterprise-Id", "true") + req.Header.Set("X-No-Department-Info", "true") + req.Header.Set("X-Product", "SaaS") + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("X-Request-ID", requestID) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("codebuddy: auth state request failed: %w", err) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("codebuddy auth state: close body error: %v", errClose) + } + }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("codebuddy: failed to read auth state response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("codebuddy: auth state request returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *struct { + State string `json:"state"` + AuthURL string `json:"authUrl"` + } `json:"data"` + } + if err = json.Unmarshal(bodyBytes, &result); err != nil { + return nil, fmt.Errorf("codebuddy: failed to parse auth state response: %w", err) + } + if result.Code != codeSuccess { + return nil, fmt.Errorf("codebuddy: auth state request failed with code %d: %s", result.Code, result.Msg) + } + if result.Data == nil || result.Data.State == "" || result.Data.AuthURL == "" { + return nil, fmt.Errorf("codebuddy: auth state response missing state or authUrl") + } + + return &AuthState{ + State: result.Data.State, + AuthURL: result.Data.AuthURL, + }, nil +} + +type pollResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + RequestID string `json:"requestId"` + Data *struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + TokenType string `json:"tokenType"` + Domain string `json:"domain"` + } `json:"data"` +} + +// doPollRequest 执行单次轮询请求,安全读取并关闭响应体 +func (a *CodeBuddyAuth) doPollRequest(ctx context.Context, pollURL string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) + if err != nil { + return nil, 0, fmt.Errorf("%w: %v", ErrTokenFetchFailed, err) + } + a.applyPollHeaders(req) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, 0, err + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("codebuddy poll: close body error: %v", errClose) + } + }() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, fmt.Errorf("codebuddy poll: failed to read response body: %w", err) + } + return body, resp.StatusCode, nil +} + +// PollForToken polls until the user completes browser authorization and returns auth data. +func (a *CodeBuddyAuth) PollForToken(ctx context.Context, state string) (*CodeBuddyTokenStorage, error) { + deadline := time.Now().Add(maxPollDuration) + pollURL := fmt.Sprintf("%s%s?state=%s", a.baseURL, codeBuddyTokenPath, url.QueryEscape(state)) + + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(pollInterval): + } + + body, statusCode, err := a.doPollRequest(ctx, pollURL) + if err != nil { + log.Debugf("codebuddy poll: request error: %v", err) + continue + } + + if statusCode != http.StatusOK { + log.Debugf("codebuddy poll: unexpected status %d", statusCode) + continue + } + + var result pollResponse + if err := json.Unmarshal(body, &result); err != nil { + continue + } + + switch result.Code { + case codeSuccess: + if result.Data == nil { + return nil, fmt.Errorf("%w: empty data in response", ErrTokenFetchFailed) + } + userID, _ := a.DecodeUserID(result.Data.AccessToken) + return &CodeBuddyTokenStorage{ + AccessToken: result.Data.AccessToken, + RefreshToken: result.Data.RefreshToken, + ExpiresIn: result.Data.ExpiresIn, + TokenType: result.Data.TokenType, + Domain: result.Data.Domain, + UserID: userID, + Type: "codebuddy", + }, nil + case codeLoginPending: + // continue polling + default: + // TODO: when the CodeBuddy API error code for user denial is known, + // return ErrAccessDenied here instead of ErrTokenFetchFailed. + return nil, fmt.Errorf("%w: server returned code %d: %s", ErrTokenFetchFailed, result.Code, result.Msg) + } + } + return nil, ErrPollingTimeout +} + +// DecodeUserID decodes the sub field from a JWT access token as the user ID. +func (a *CodeBuddyAuth) DecodeUserID(accessToken string) (string, error) { + parts := strings.Split(accessToken, ".") + if len(parts) < 2 { + return "", ErrJWTDecodeFailed + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrJWTDecodeFailed, err) + } + var claims struct { + Sub string `json:"sub"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("%w: %v", ErrJWTDecodeFailed, err) + } + if claims.Sub == "" { + return "", fmt.Errorf("%w: sub claim is empty", ErrJWTDecodeFailed) + } + return claims.Sub, nil +} + +// RefreshToken exchanges a refresh token for a new access token. +// It calls POST /v2/plugin/auth/token/refresh with the required headers. +func (a *CodeBuddyAuth) RefreshToken(ctx context.Context, accessToken, refreshToken, userID, domain string) (*CodeBuddyTokenStorage, error) { + if domain == "" { + domain = DefaultDomain + } + refreshURL := fmt.Sprintf("%s%s", a.baseURL, codeBuddyRefreshPath) + body := []byte("{}") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, refreshURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("codebuddy: failed to create refresh request: %w", err) + } + + requestID := strings.ReplaceAll(uuid.New().String(), "-", "") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("X-Domain", domain) + req.Header.Set("X-Refresh-Token", refreshToken) + req.Header.Set("X-Auth-Refresh-Source", "plugin") + req.Header.Set("X-Request-ID", requestID) + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("X-User-Id", userID) + req.Header.Set("X-Product", "SaaS") + req.Header.Set("User-Agent", UserAgent) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("codebuddy: refresh request failed: %w", err) + } + defer func() { + if errClose := resp.Body.Close(); errClose != nil { + log.Errorf("codebuddy refresh: close body error: %v", errClose) + } + }() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("codebuddy: failed to read refresh response: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("codebuddy: refresh token rejected (status %d)", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("codebuddy: refresh failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + RefreshExpiresIn int64 `json:"refreshExpiresIn"` + TokenType string `json:"tokenType"` + Domain string `json:"domain"` + } `json:"data"` + } + if err = json.Unmarshal(bodyBytes, &result); err != nil { + return nil, fmt.Errorf("codebuddy: failed to parse refresh response: %w", err) + } + if result.Code != codeSuccess { + return nil, fmt.Errorf("codebuddy: refresh failed with code %d: %s", result.Code, result.Msg) + } + if result.Data == nil { + return nil, fmt.Errorf("codebuddy: empty data in refresh response") + } + + newUserID, _ := a.DecodeUserID(result.Data.AccessToken) + if newUserID == "" { + newUserID = userID + } + tokenDomain := result.Data.Domain + if tokenDomain == "" { + tokenDomain = domain + } + + return &CodeBuddyTokenStorage{ + AccessToken: result.Data.AccessToken, + RefreshToken: result.Data.RefreshToken, + ExpiresIn: result.Data.ExpiresIn, + RefreshExpiresIn: result.Data.RefreshExpiresIn, + TokenType: result.Data.TokenType, + Domain: tokenDomain, + UserID: newUserID, + Type: "codebuddy", + }, nil +} + +func (a *CodeBuddyAuth) applyPollHeaders(req *http.Request) { + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + req.Header.Set("X-No-Authorization", "true") + req.Header.Set("X-No-User-Id", "true") + req.Header.Set("X-No-Enterprise-Id", "true") + req.Header.Set("X-No-Department-Info", "true") + req.Header.Set("X-Product", "SaaS") +} diff --git a/internal/auth/codebuddy/codebuddy_auth_http_test.go b/internal/auth/codebuddy/codebuddy_auth_http_test.go new file mode 100644 index 0000000000..125d7c0343 --- /dev/null +++ b/internal/auth/codebuddy/codebuddy_auth_http_test.go @@ -0,0 +1,285 @@ +package codebuddy + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// newTestAuth creates a CodeBuddyAuth pointing at the given test server. +func newTestAuth(serverURL string) *CodeBuddyAuth { + return &CodeBuddyAuth{ + httpClient: http.DefaultClient, + baseURL: serverURL, + } +} + +// fakeJWT builds a minimal JWT with the given sub claim for testing. +func fakeJWT(sub string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) + payload, _ := json.Marshal(map[string]any{"sub": sub, "iat": 1234567890}) + encodedPayload := base64.RawURLEncoding.EncodeToString(payload) + return header + "." + encodedPayload + ".sig" +} + +// --- FetchAuthState tests --- + +func TestFetchAuthState_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if got := r.URL.Path; got != codeBuddyStatePath { + t.Errorf("expected path %s, got %s", codeBuddyStatePath, got) + } + if got := r.URL.Query().Get("platform"); got != "CLI" { + t.Errorf("expected platform=CLI, got %s", got) + } + if got := r.Header.Get("User-Agent"); got != UserAgent { + t.Errorf("expected User-Agent %s, got %s", UserAgent, got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "msg": "ok", + "data": map[string]any{ + "state": "test-state-abc", + "authUrl": "https://example.com/login?state=test-state-abc", + }, + }) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + result, err := auth.FetchAuthState(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.State != "test-state-abc" { + t.Errorf("expected state 'test-state-abc', got '%s'", result.State) + } + if result.AuthURL != "https://example.com/login?state=test-state-abc" { + t.Errorf("unexpected authURL: %s", result.AuthURL) + } +} + +func TestFetchAuthState_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal error")) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + _, err := auth.FetchAuthState(context.Background()) + if err == nil { + t.Fatal("expected error for non-200 status") + } +} + +func TestFetchAuthState_APIErrorCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 10001, + "msg": "rate limited", + }) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + _, err := auth.FetchAuthState(context.Background()) + if err == nil { + t.Fatal("expected error for non-zero code") + } +} + +func TestFetchAuthState_MissingData(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "msg": "ok", + "data": map[string]any{ + "state": "", + "authUrl": "", + }, + }) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + _, err := auth.FetchAuthState(context.Background()) + if err == nil { + t.Fatal("expected error for empty state/authUrl") + } +} + +// --- RefreshToken tests --- + +func TestRefreshToken_Success(t *testing.T) { + newAccessToken := fakeJWT("refreshed-user-456") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if got := r.URL.Path; got != codeBuddyRefreshPath { + t.Errorf("expected path %s, got %s", codeBuddyRefreshPath, got) + } + if got := r.Header.Get("X-Refresh-Token"); got != "old-refresh-token" { + t.Errorf("expected X-Refresh-Token 'old-refresh-token', got '%s'", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer old-access-token" { + t.Errorf("expected Authorization 'Bearer old-access-token', got '%s'", got) + } + if got := r.Header.Get("X-User-Id"); got != "user-123" { + t.Errorf("expected X-User-Id 'user-123', got '%s'", got) + } + if got := r.Header.Get("X-Domain"); got != "custom.domain.com" { + t.Errorf("expected X-Domain 'custom.domain.com', got '%s'", got) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "msg": "ok", + "data": map[string]any{ + "accessToken": newAccessToken, + "refreshToken": "new-refresh-token", + "expiresIn": 3600, + "refreshExpiresIn": 86400, + "tokenType": "bearer", + "domain": "custom.domain.com", + }, + }) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + storage, err := auth.RefreshToken(context.Background(), "old-access-token", "old-refresh-token", "user-123", "custom.domain.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if storage.AccessToken != newAccessToken { + t.Errorf("expected new access token, got '%s'", storage.AccessToken) + } + if storage.RefreshToken != "new-refresh-token" { + t.Errorf("expected 'new-refresh-token', got '%s'", storage.RefreshToken) + } + if storage.UserID != "refreshed-user-456" { + t.Errorf("expected userID 'refreshed-user-456', got '%s'", storage.UserID) + } + if storage.ExpiresIn != 3600 { + t.Errorf("expected expiresIn 3600, got %d", storage.ExpiresIn) + } + if storage.RefreshExpiresIn != 86400 { + t.Errorf("expected refreshExpiresIn 86400, got %d", storage.RefreshExpiresIn) + } + if storage.Domain != "custom.domain.com" { + t.Errorf("expected domain 'custom.domain.com', got '%s'", storage.Domain) + } + if storage.Type != "codebuddy" { + t.Errorf("expected type 'codebuddy', got '%s'", storage.Type) + } +} + +func TestRefreshToken_DefaultDomain(t *testing.T) { + var receivedDomain string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedDomain = r.Header.Get("X-Domain") + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "msg": "ok", + "data": map[string]any{ + "accessToken": fakeJWT("user-1"), + "refreshToken": "rt", + "expiresIn": 3600, + "tokenType": "bearer", + "domain": DefaultDomain, + }, + }) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + _, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedDomain != DefaultDomain { + t.Errorf("expected default domain '%s', got '%s'", DefaultDomain, receivedDomain) + } +} + +func TestRefreshToken_Unauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + _, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "d") + if err == nil { + t.Fatal("expected error for 401 response") + } +} + +func TestRefreshToken_Forbidden(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + _, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "d") + if err == nil { + t.Fatal("expected error for 403 response") + } +} + +func TestRefreshToken_APIErrorCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 40001, + "msg": "invalid refresh token", + }) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + _, err := auth.RefreshToken(context.Background(), "at", "rt", "uid", "d") + if err == nil { + t.Fatal("expected error for non-zero API code") + } +} + +func TestRefreshToken_FallbackUserIDAndDomain(t *testing.T) { + // When the new access token cannot be decoded for userID, it should fall back to the provided one. + // When the response domain is empty, it should fall back to the request domain. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": 0, + "msg": "ok", + "data": map[string]any{ + "accessToken": "not-a-valid-jwt", + "refreshToken": "new-rt", + "expiresIn": 7200, + "tokenType": "bearer", + "domain": "", + }, + }) + })) + defer srv.Close() + + auth := newTestAuth(srv.URL) + storage, err := auth.RefreshToken(context.Background(), "at", "rt", "original-uid", "original.domain.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if storage.UserID != "original-uid" { + t.Errorf("expected fallback userID 'original-uid', got '%s'", storage.UserID) + } + if storage.Domain != "original.domain.com" { + t.Errorf("expected fallback domain 'original.domain.com', got '%s'", storage.Domain) + } +} diff --git a/internal/auth/codebuddy/codebuddy_auth_test.go b/internal/auth/codebuddy/codebuddy_auth_test.go new file mode 100644 index 0000000000..f4ff553f65 --- /dev/null +++ b/internal/auth/codebuddy/codebuddy_auth_test.go @@ -0,0 +1,22 @@ +package codebuddy_test + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codebuddy" +) + +func TestDecodeUserID_ValidJWT(t *testing.T) { + // JWT payload: {"sub":"test-user-id-123","iat":1234567890} + // base64url encode: eyJzdWIiOiJ0ZXN0LXVzZXItaWQtMTIzIiwiaWF0IjoxMjM0NTY3ODkwfQ + token := "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ0ZXN0LXVzZXItaWQtMTIzIiwiaWF0IjoxMjM0NTY3ODkwfQ.sig" + auth := codebuddy.NewCodeBuddyAuth(nil) + userID, err := auth.DecodeUserID(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if userID != "test-user-id-123" { + t.Errorf("expected 'test-user-id-123', got '%s'", userID) + } +} + diff --git a/internal/auth/codebuddy/errors.go b/internal/auth/codebuddy/errors.go new file mode 100644 index 0000000000..7a35809bae --- /dev/null +++ b/internal/auth/codebuddy/errors.go @@ -0,0 +1,25 @@ +package codebuddy + +import "errors" + +var ( + ErrPollingTimeout = errors.New("codebuddy: polling timeout, user did not authorize in time") + ErrAccessDenied = errors.New("codebuddy: access denied by user") + ErrTokenFetchFailed = errors.New("codebuddy: failed to fetch token from server") + ErrJWTDecodeFailed = errors.New("codebuddy: failed to decode JWT token") +) + +func GetUserFriendlyMessage(err error) string { + switch { + case errors.Is(err, ErrPollingTimeout): + return "Authentication timed out. Please try again." + case errors.Is(err, ErrAccessDenied): + return "Access denied. Please try again and approve the login request." + case errors.Is(err, ErrJWTDecodeFailed): + return "Failed to decode token. Please try logging in again." + case errors.Is(err, ErrTokenFetchFailed): + return "Failed to fetch token from server. Please try again." + default: + return "Authentication failed: " + err.Error() + } +} diff --git a/internal/auth/codebuddy/token.go b/internal/auth/codebuddy/token.go new file mode 100644 index 0000000000..6888b7277c --- /dev/null +++ b/internal/auth/codebuddy/token.go @@ -0,0 +1,65 @@ +// Package codebuddy provides authentication and token management functionality +// for CodeBuddy AI services. It handles OAuth2 token storage, serialization, +// and retrieval for maintaining authenticated sessions with the CodeBuddy API. +package codebuddy + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/misc" +) + +// CodeBuddyTokenStorage stores OAuth token information for CodeBuddy API authentication. +// It maintains compatibility with the existing auth system while adding CodeBuddy-specific fields +// for managing access tokens and user account information. +type CodeBuddyTokenStorage struct { + // AccessToken is the OAuth2 access token used for authenticating API requests. + AccessToken string `json:"access_token"` + // RefreshToken is the OAuth2 refresh token used to obtain new access tokens. + RefreshToken string `json:"refresh_token"` + // ExpiresIn is the number of seconds until the access token expires. + ExpiresIn int64 `json:"expires_in"` + // RefreshExpiresIn is the number of seconds until the refresh token expires. + RefreshExpiresIn int64 `json:"refresh_expires_in,omitempty"` + // TokenType is the type of token, typically "bearer". + TokenType string `json:"token_type"` + // Domain is the CodeBuddy service domain/region. + Domain string `json:"domain"` + // UserID is the user ID associated with this token. + UserID string `json:"user_id"` + // Type indicates the authentication provider type, always "codebuddy" for this storage. + Type string `json:"type"` +} + +// SaveTokenToFile serializes the CodeBuddy token storage to a JSON file. +// This method creates the necessary directory structure and writes the token +// data in JSON format to the specified file path for persistent storage. +// +// Parameters: +// - authFilePath: The full path where the token file should be saved +// +// Returns: +// - error: An error if the operation fails, nil otherwise +func (s *CodeBuddyTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) + s.Type = "codebuddy" + if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + f, err := os.OpenFile(authFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to create token file: %w", err) + } + defer func() { + _ = f.Close() + }() + + if err = json.NewEncoder(f).Encode(s); err != nil { + return fmt.Errorf("failed to write token to file: %w", err) + } + return nil +} diff --git a/internal/cmd/auth_manager.go b/internal/cmd/auth_manager.go index ea7a05321f..83f42e0c12 100644 --- a/internal/cmd/auth_manager.go +++ b/internal/cmd/auth_manager.go @@ -5,8 +5,7 @@ import ( ) // newAuthManager creates a new authentication manager instance with all supported -// authenticators and a file-based token store. It initializes authenticators for -// Gemini, Codex, Claude, Qwen, IFlow, Antigravity, and GitHub Copilot providers. +// authenticators and a file-based token store. // // Returns: // - *sdkAuth.Manager: A configured authentication manager instance @@ -24,6 +23,7 @@ func newAuthManager() *sdkAuth.Manager { sdkAuth.NewGitHubCopilotAuthenticator(), sdkAuth.NewKiloAuthenticator(), sdkAuth.NewGitLabAuthenticator(), + sdkAuth.NewCodeBuddyAuthenticator(), ) return manager } diff --git a/internal/cmd/codebuddy_login.go b/internal/cmd/codebuddy_login.go new file mode 100644 index 0000000000..0f834fa6fb --- /dev/null +++ b/internal/cmd/codebuddy_login.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" + log "github.com/sirupsen/logrus" +) + +// DoCodeBuddyLogin triggers the browser OAuth polling flow for CodeBuddy and saves tokens. +// It initiates the OAuth authentication, displays the user code for the user to enter +// at the CodeBuddy verification URL, and waits for authorization before saving the tokens. +// +// Parameters: +// - cfg: The application configuration containing proxy and auth directory settings +// - options: Login options including browser behavior settings +func DoCodeBuddyLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + manager := newAuthManager() + authOpts := &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + Metadata: map[string]string{}, + } + + record, savedPath, err := manager.Login(context.Background(), "codebuddy", cfg, authOpts) + if err != nil { + log.Errorf("CodeBuddy authentication failed: %v", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + if record != nil && record.Label != "" { + fmt.Printf("Authenticated as %s\n", record.Label) + } + fmt.Println("CodeBuddy authentication successful!") +} diff --git a/internal/registry/model_definitions.go b/internal/registry/model_definitions.go index 8896a9dfdb..82d0926563 100644 --- a/internal/registry/model_definitions.go +++ b/internal/registry/model_definitions.go @@ -88,6 +88,87 @@ func GetAntigravityModels() []*ModelInfo { return cloneModelInfos(getModels().Antigravity) } +// GetCodeBuddyModels returns the available models for CodeBuddy (Tencent). +// These models are served through the copilot.tencent.com API. +func GetCodeBuddyModels() []*ModelInfo { + now := int64(1748044800) // 2025-05-24 + return []*ModelInfo{ + { + ID: "glm-5.0", + Object: "model", + Created: now, + OwnedBy: "tencent", + Type: "codebuddy", + DisplayName: "GLM-5.0", + Description: "GLM-5.0 via CodeBuddy", + ContextLength: 128000, + MaxCompletionTokens: 32768, + SupportedEndpoints: []string{"/chat/completions"}, + }, + { + ID: "glm-4.7", + Object: "model", + Created: now, + OwnedBy: "tencent", + Type: "codebuddy", + DisplayName: "GLM-4.7", + Description: "GLM-4.7 via CodeBuddy", + ContextLength: 128000, + MaxCompletionTokens: 32768, + SupportedEndpoints: []string{"/chat/completions"}, + }, + { + ID: "minimax-m2.5", + Object: "model", + Created: now, + OwnedBy: "tencent", + Type: "codebuddy", + DisplayName: "MiniMax M2.5", + Description: "MiniMax M2.5 via CodeBuddy", + ContextLength: 200000, + MaxCompletionTokens: 32768, + SupportedEndpoints: []string{"/chat/completions"}, + }, + { + ID: "kimi-k2.5", + Object: "model", + Created: now, + OwnedBy: "tencent", + Type: "codebuddy", + DisplayName: "Kimi K2.5", + Description: "Kimi K2.5 via CodeBuddy", + ContextLength: 128000, + MaxCompletionTokens: 32768, + SupportedEndpoints: []string{"/chat/completions"}, + }, + { + ID: "deepseek-v3-2-volc", + Object: "model", + Created: now, + OwnedBy: "tencent", + Type: "codebuddy", + DisplayName: "DeepSeek V3.2 (Volc)", + Description: "DeepSeek V3.2 via CodeBuddy (Volcano Engine)", + ContextLength: 128000, + MaxCompletionTokens: 32768, + SupportedEndpoints: []string{"/chat/completions"}, + }, + { + ID: "hunyuan-2.0-thinking", + Object: "model", + Created: now, + OwnedBy: "tencent", + Type: "codebuddy", + DisplayName: "Hunyuan 2.0 Thinking", + Description: "Tencent Hunyuan 2.0 Thinking via CodeBuddy", + ContextLength: 128000, + MaxCompletionTokens: 32768, + Thinking: &ThinkingSupport{ZeroAllowed: true}, + SupportedEndpoints: []string{"/chat/completions"}, + }, + } +} + // cloneModelInfos returns a shallow copy of the slice with each element deep-cloned. func cloneModelInfos(models []*ModelInfo) []*ModelInfo { if len(models) == 0 { @@ -148,6 +229,8 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo { return GetAmazonQModels() case "antigravity": return GetAntigravityModels() + case "codebuddy": + return GetCodeBuddyModels() default: return nil } @@ -176,6 +259,7 @@ func LookupStaticModelInfo(modelID string) *ModelInfo { GetKiroModels(), GetKiloModels(), GetAmazonQModels(), + GetCodeBuddyModels(), } for _, models := range allModels { for _, m := range models { diff --git a/internal/runtime/executor/codebuddy_executor.go b/internal/runtime/executor/codebuddy_executor.go new file mode 100644 index 0000000000..0bc56354f2 --- /dev/null +++ b/internal/runtime/executor/codebuddy_executor.go @@ -0,0 +1,343 @@ +package executor + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codebuddy" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "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" + sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + log "github.com/sirupsen/logrus" +) + +const ( + codeBuddyChatPath = "/v2/chat/completions" + codeBuddyAuthType = "codebuddy" +) + +// CodeBuddyExecutor handles requests to the CodeBuddy API. +type CodeBuddyExecutor struct { + cfg *config.Config +} + +// NewCodeBuddyExecutor creates a new CodeBuddy executor instance. +func NewCodeBuddyExecutor(cfg *config.Config) *CodeBuddyExecutor { + return &CodeBuddyExecutor{cfg: cfg} +} + +// Identifier returns the unique identifier for this executor. +func (e *CodeBuddyExecutor) Identifier() string { return codeBuddyAuthType } + +// codeBuddyCredentials extracts the access token and domain from auth metadata. +func codeBuddyCredentials(auth *cliproxyauth.Auth) (accessToken, userID, domain string) { + if auth == nil { + return "", "", "" + } + accessToken = metaStringValue(auth.Metadata, "access_token") + userID = metaStringValue(auth.Metadata, "user_id") + domain = metaStringValue(auth.Metadata, "domain") + if domain == "" { + domain = codebuddy.DefaultDomain + } + return +} + +// PrepareRequest prepares the HTTP request before execution. +func (e *CodeBuddyExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { + if req == nil { + return nil + } + accessToken, userID, domain := codeBuddyCredentials(auth) + if accessToken == "" { + return fmt.Errorf("codebuddy: missing access token") + } + e.applyHeaders(req, accessToken, userID, domain) + return nil +} + +// HttpRequest executes a raw HTTP request. +func (e *CodeBuddyExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { + if req == nil { + return nil, fmt.Errorf("codebuddy executor: request is nil") + } + if ctx == nil { + ctx = req.Context() + } + httpReq := req.WithContext(ctx) + if err := e.PrepareRequest(httpReq, auth); err != nil { + return nil, err + } + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + return httpClient.Do(httpReq) +} + +// Execute performs a non-streaming request. +func (e *CodeBuddyExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { + baseModel := thinking.ParseSuffix(req.Model).ModelName + + reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) + defer reporter.trackFailure(ctx, &err) + + accessToken, userID, domain := codeBuddyCredentials(auth) + if accessToken == "" { + return resp, fmt.Errorf("codebuddy: missing access token") + } + + from := opts.SourceFormat + to := sdktranslator.FromString("openai") + + originalPayloadSource := req.Payload + if len(opts.OriginalRequest) > 0 { + originalPayloadSource = opts.OriginalRequest + } + originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayloadSource, false) + translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false) + requestedModel := payloadRequestedModel(opts, req.Model) + translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel) + + translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + if err != nil { + return resp, err + } + + url := codebuddy.BaseURL + codeBuddyChatPath + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) + if err != nil { + return resp, err + } + e.applyHeaders(httpReq, accessToken, userID, domain) + + var authID, authLabel, authType, authValue string + if auth != nil { + authID = auth.ID + authLabel = auth.Label + authType, authValue = auth.AccountInfo() + } + recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ + URL: url, + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: translated, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpResp, err := httpClient.Do(httpReq) + if err != nil { + recordAPIResponseError(ctx, e.cfg, err) + return resp, err + } + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("codebuddy executor: close response body error: %v", errClose) + } + }() + + recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + if !isHTTPSuccess(httpResp.StatusCode) { + b, _ := io.ReadAll(httpResp.Body) + appendAPIResponseChunk(ctx, e.cfg, b) + log.Debugf("codebuddy executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) + err = statusErr{code: httpResp.StatusCode, msg: string(b)} + return resp, err + } + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + recordAPIResponseError(ctx, e.cfg, err) + return resp, err + } + appendAPIResponseChunk(ctx, e.cfg, body) + reporter.publish(ctx, parseOpenAIUsage(body)) + reporter.ensurePublished(ctx) + + var param any + out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, body, ¶m) + resp = cliproxyexecutor.Response{Payload: []byte(out), Headers: httpResp.Header.Clone()} + return resp, nil +} + +// ExecuteStream performs a streaming request. +func (e *CodeBuddyExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (_ *cliproxyexecutor.StreamResult, err error) { + baseModel := thinking.ParseSuffix(req.Model).ModelName + + reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) + defer reporter.trackFailure(ctx, &err) + + accessToken, userID, domain := codeBuddyCredentials(auth) + if accessToken == "" { + return nil, fmt.Errorf("codebuddy: missing access token") + } + + from := opts.SourceFormat + to := sdktranslator.FromString("openai") + + originalPayloadSource := req.Payload + if len(opts.OriginalRequest) > 0 { + originalPayloadSource = opts.OriginalRequest + } + originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayloadSource, true) + translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true) + requestedModel := payloadRequestedModel(opts, req.Model) + translated = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", translated, originalTranslated, requestedModel) + + translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier()) + if err != nil { + return nil, err + } + + url := codebuddy.BaseURL + codeBuddyChatPath + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(translated)) + if err != nil { + return nil, err + } + e.applyHeaders(httpReq, accessToken, userID, domain) + httpReq.Header.Set("Accept", "text/event-stream") + httpReq.Header.Set("Cache-Control", "no-cache") + + var authID, authLabel, authType, authValue string + if auth != nil { + authID = auth.ID + authLabel = auth.Label + authType, authValue = auth.AccountInfo() + } + recordAPIRequest(ctx, e.cfg, upstreamRequestLog{ + URL: url, + Method: http.MethodPost, + Headers: httpReq.Header.Clone(), + Body: translated, + Provider: e.Identifier(), + AuthID: authID, + AuthLabel: authLabel, + AuthType: authType, + AuthValue: authValue, + }) + + httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) + httpResp, err := httpClient.Do(httpReq) + if err != nil { + recordAPIResponseError(ctx, e.cfg, err) + return nil, err + } + + recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) + if !isHTTPSuccess(httpResp.StatusCode) { + b, _ := io.ReadAll(httpResp.Body) + appendAPIResponseChunk(ctx, e.cfg, b) + httpResp.Body.Close() + log.Debugf("codebuddy executor: upstream error status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) + err = statusErr{code: httpResp.StatusCode, msg: string(b)} + return nil, err + } + + out := make(chan cliproxyexecutor.StreamChunk) + go func() { + defer close(out) + defer func() { + if errClose := httpResp.Body.Close(); errClose != nil { + log.Errorf("codebuddy executor: close stream body error: %v", errClose) + } + }() + + scanner := bufio.NewScanner(httpResp.Body) + scanner.Buffer(nil, maxScannerBufferSize) + var param any + for scanner.Scan() { + line := scanner.Bytes() + appendAPIResponseChunk(ctx, e.cfg, line) + if detail, ok := parseOpenAIStreamUsage(line); ok { + reporter.publish(ctx, detail) + } + if len(line) == 0 { + continue + } + if !bytes.HasPrefix(line, []byte("data:")) { + continue + } + chunks := sdktranslator.TranslateStream(ctx, to, from, req.Model, opts.OriginalRequest, translated, bytes.Clone(line), ¶m) + for i := range chunks { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} + } + } + if errScan := scanner.Err(); errScan != nil { + recordAPIResponseError(ctx, e.cfg, errScan) + reporter.publishFailure(ctx) + out <- cliproxyexecutor.StreamChunk{Err: errScan} + } + reporter.ensurePublished(ctx) + }() + + return &cliproxyexecutor.StreamResult{ + Headers: httpResp.Header.Clone(), + Chunks: out, + }, nil +} + +// Refresh exchanges the CodeBuddy refresh token for a new access token. +func (e *CodeBuddyExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, fmt.Errorf("codebuddy: missing auth") + } + + refreshToken := metaStringValue(auth.Metadata, "refresh_token") + if refreshToken == "" { + log.Debugf("codebuddy executor: no refresh token available, skipping refresh") + return auth, nil + } + + accessToken, userID, domain := codeBuddyCredentials(auth) + + authSvc := codebuddy.NewCodeBuddyAuth(e.cfg) + storage, err := authSvc.RefreshToken(ctx, accessToken, refreshToken, userID, domain) + if err != nil { + return nil, fmt.Errorf("codebuddy: token refresh failed: %w", err) + } + + updated := auth.Clone() + updated.Metadata["access_token"] = storage.AccessToken + if storage.RefreshToken != "" { + updated.Metadata["refresh_token"] = storage.RefreshToken + } + updated.Metadata["expires_in"] = storage.ExpiresIn + updated.Metadata["domain"] = storage.Domain + if storage.UserID != "" { + updated.Metadata["user_id"] = storage.UserID + } + now := time.Now() + updated.UpdatedAt = now + updated.LastRefreshedAt = now + + return updated, nil +} + +// CountTokens is not supported for CodeBuddy. +func (e *CodeBuddyExecutor) CountTokens(_ context.Context, _ *cliproxyauth.Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { + return cliproxyexecutor.Response{}, fmt.Errorf("codebuddy: count tokens not supported") +} + +// applyHeaders sets required headers for CodeBuddy API requests. +func (e *CodeBuddyExecutor) applyHeaders(req *http.Request, accessToken, userID, domain string) { + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", codebuddy.UserAgent) + req.Header.Set("X-User-Id", userID) + req.Header.Set("X-Domain", domain) + req.Header.Set("X-Product", "SaaS") + req.Header.Set("X-IDE-Type", "CLI") + req.Header.Set("X-IDE-Name", "CLI") + req.Header.Set("X-IDE-Version", "2.63.2") + req.Header.Set("X-Requested-With", "XMLHttpRequest") +} diff --git a/sdk/auth/codebuddy.go b/sdk/auth/codebuddy.go new file mode 100644 index 0000000000..dd918c499f --- /dev/null +++ b/sdk/auth/codebuddy.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "fmt" + "time" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codebuddy" + "github.com/router-for-me/CLIProxyAPI/v6/internal/browser" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + log "github.com/sirupsen/logrus" +) + +// CodeBuddyAuthenticator implements the browser OAuth polling flow for CodeBuddy. +type CodeBuddyAuthenticator struct{} + +// NewCodeBuddyAuthenticator constructs a new CodeBuddy authenticator. +func NewCodeBuddyAuthenticator() Authenticator { + return &CodeBuddyAuthenticator{} +} + +// Provider returns the provider key for codebuddy. +func (CodeBuddyAuthenticator) Provider() string { + return "codebuddy" +} + +// codeBuddyRefreshLead is the duration before token expiry when a refresh should be attempted. +var codeBuddyRefreshLead = 24 * time.Hour + +// RefreshLead returns how soon before expiry a refresh should be attempted. +// CodeBuddy tokens have a long validity period, so we refresh 24 hours before expiry. +func (CodeBuddyAuthenticator) RefreshLead() *time.Duration { + return &codeBuddyRefreshLead +} + +// Login initiates the browser OAuth flow for CodeBuddy. +func (a CodeBuddyAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("codebuddy: configuration is required") + } + if opts == nil { + opts = &LoginOptions{} + } + if ctx == nil { + ctx = context.Background() + } + + authSvc := codebuddy.NewCodeBuddyAuth(cfg) + + authState, err := authSvc.FetchAuthState(ctx) + if err != nil { + return nil, fmt.Errorf("codebuddy: failed to fetch auth state: %w", err) + } + + fmt.Printf("\nPlease open the following URL in your browser to login:\n\n %s\n\n", authState.AuthURL) + fmt.Println("Waiting for authorization...") + + if !opts.NoBrowser { + if browser.IsAvailable() { + if errOpen := browser.OpenURL(authState.AuthURL); errOpen != nil { + log.Debugf("codebuddy: failed to open browser: %v", errOpen) + } + } + } + + storage, err := authSvc.PollForToken(ctx, authState.State) + if err != nil { + return nil, fmt.Errorf("codebuddy: %s: %w", codebuddy.GetUserFriendlyMessage(err), err) + } + + fmt.Printf("\nSuccessfully logged in! (User ID: %s)\n", storage.UserID) + + authID := fmt.Sprintf("codebuddy-%s.json", storage.UserID) + + label := storage.UserID + if label == "" { + label = "codebuddy-user" + } + + return &coreauth.Auth{ + ID: authID, + Provider: a.Provider(), + FileName: authID, + Label: label, + Storage: storage, + Metadata: map[string]any{ + "access_token": storage.AccessToken, + "refresh_token": storage.RefreshToken, + "user_id": storage.UserID, + "domain": storage.Domain, + "expires_in": storage.ExpiresIn, + }, + }, nil +} diff --git a/sdk/auth/refresh_registry.go b/sdk/auth/refresh_registry.go index 411950aefd..651ba5407f 100644 --- a/sdk/auth/refresh_registry.go +++ b/sdk/auth/refresh_registry.go @@ -18,6 +18,7 @@ func init() { registerRefreshLead("kiro", func() Authenticator { return NewKiroAuthenticator() }) registerRefreshLead("github-copilot", func() Authenticator { return NewGitHubCopilotAuthenticator() }) registerRefreshLead("gitlab", func() Authenticator { return NewGitLabAuthenticator() }) + registerRefreshLead("codebuddy", func() Authenticator { return NewCodeBuddyAuthenticator() }) } func registerRefreshLead(provider string, factory func() Authenticator) { diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index c560f71505..2503f00b09 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -443,6 +443,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg)) case "github-copilot": s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) + case "codebuddy": + s.coreManager.RegisterExecutor(executor.NewCodeBuddyExecutor(s.cfg)) case "gitlab": s.coreManager.RegisterExecutor(executor.NewGitLabExecutor(s.cfg)) default: @@ -954,6 +956,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "gitlab": models = executor.GitLabModelsFromAuth(a) models = applyExcludedModels(models, excluded) + case "codebuddy": + models = registry.GetCodeBuddyModels() + models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config if s.cfg != nil { From c28b65f8498526aa35cca5f513699b643f235b67 Mon Sep 17 00:00:00 2001 From: lwiles692 Date: Thu, 19 Mar 2026 09:46:40 +0800 Subject: [PATCH 2/3] Update internal/auth/codebuddy/codebuddy_auth.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- internal/auth/codebuddy/codebuddy_auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/auth/codebuddy/codebuddy_auth.go b/internal/auth/codebuddy/codebuddy_auth.go index 9982c0fc04..4afc58401e 100644 --- a/internal/auth/codebuddy/codebuddy_auth.go +++ b/internal/auth/codebuddy/codebuddy_auth.go @@ -131,7 +131,7 @@ type pollResponse struct { } `json:"data"` } -// doPollRequest 执行单次轮询请求,安全读取并关闭响应体 +// doPollRequest performs a single polling request, safely reading and closing the response body func (a *CodeBuddyAuth) doPollRequest(ctx context.Context, pollURL string) ([]byte, int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil) if err != nil { From 7275e99b4197426e4d59a42b55f653e40bac10c0 Mon Sep 17 00:00:00 2001 From: lwiles692 Date: Thu, 19 Mar 2026 09:46:59 +0800 Subject: [PATCH 3/3] Update internal/auth/codebuddy/codebuddy_auth.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- internal/auth/codebuddy/codebuddy_auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/auth/codebuddy/codebuddy_auth.go b/internal/auth/codebuddy/codebuddy_auth.go index 4afc58401e..ce0b803a2c 100644 --- a/internal/auth/codebuddy/codebuddy_auth.go +++ b/internal/auth/codebuddy/codebuddy_auth.go @@ -63,7 +63,7 @@ func (a *CodeBuddyAuth) FetchAuthState(ctx context.Context) (*AuthState, error) return nil, fmt.Errorf("codebuddy: failed to create auth state request: %w", err) } - requestID := strings.ReplaceAll(uuid.New().String(), "-", "") +requestID := uuid.NewString() req.Header.Set("Accept", "application/json, text/plain, */*") req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Requested-With", "XMLHttpRequest")