diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml new file mode 100644 index 0000000000..b8a831c87d --- /dev/null +++ b/.github/workflows/lint-test.yml @@ -0,0 +1,18 @@ +name: Lint & Test + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +jobs: + lint-test: + name: lint-test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: KooshaPari/phenotypeActions/actions/lint-test@main diff --git a/.gitignore b/.gitignore index 745003cda2..db74f720fa 100644 --- a/.gitignore +++ b/.gitignore @@ -57,9 +57,26 @@ _bmad-output/* *.bak # Local worktree shelves (canonical checkout must stay clean) PROJECT-wtrees/ -.worktrees/ -cli-proxy-api-plus-integration-test -boardsync -releasebatch -.cache -worktrees/ + +# Added by Spec Kitty CLI (auto-managed) +.opencode/ +.windsurf/ +.qwen/ +.augment/ +.roo/ +.amazonq/ +.github/copilot/ +.kittify/.dashboard + + +# AI tool artifacts +.claude/ +.codex/ +.cursor/ +.gemini/ +.kittify/ +.kilocode/ +.github/prompts/ +.github/copilot-instructions.md +.claudeignore +.llmignore diff --git a/pkg/llmproxy/auth/base/token_storage.go b/pkg/llmproxy/auth/base/token_storage.go new file mode 100644 index 0000000000..83a03903f3 --- /dev/null +++ b/pkg/llmproxy/auth/base/token_storage.go @@ -0,0 +1,129 @@ +// Package base provides a shared foundation for OAuth2 token storage across all +// LLM proxy authentication providers. It centralises the common Save/Load/Clear +// file-I/O operations so that individual provider packages only need to embed +// BaseTokenStorage and add their own provider-specific fields. +package base + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" +) + +// BaseTokenStorage holds the fields and file-I/O methods that every provider +// token struct shares. Provider-specific structs embed a *BaseTokenStorage +// (or a copy by value) and extend it with their own fields. +type BaseTokenStorage struct { + // AccessToken is the OAuth2 bearer token used to authenticate API requests. + AccessToken string `json:"access_token"` + + // RefreshToken is used to obtain a new access token when the current one expires. + RefreshToken string `json:"refresh_token,omitempty"` + + // Email is the account e-mail address associated with this token. + Email string `json:"email,omitempty"` + + // Type is the provider identifier (e.g. "claude", "codex", "kimi"). + // Each provider sets this before saving so that callers can identify + // which authentication provider a credential file belongs to. + Type string `json:"type"` + + // FilePath is the on-disk path used by Save/Load/Clear. It is not + // serialised to JSON; it is populated at runtime from the caller-supplied + // authFilePath argument. + FilePath string `json:"-"` +} + +// GetAccessToken returns the OAuth2 access token. +func (b *BaseTokenStorage) GetAccessToken() string { return b.AccessToken } + +// GetRefreshToken returns the OAuth2 refresh token. +func (b *BaseTokenStorage) GetRefreshToken() string { return b.RefreshToken } + +// GetEmail returns the e-mail address associated with the token. +func (b *BaseTokenStorage) GetEmail() string { return b.Email } + +// GetType returns the provider type string. +func (b *BaseTokenStorage) GetType() string { return b.Type } + +// Save serialises v (the outer provider struct that embeds BaseTokenStorage) +// to the file at authFilePath using an atomic write (write to a temp file, +// then rename). The directory is created if it does not already exist. +// +// v must be JSON-marshallable. Passing the provider struct rather than +// BaseTokenStorage itself ensures that all provider-specific fields are +// persisted alongside the base fields. +func (b *BaseTokenStorage) Save(authFilePath string, v any) error { + safePath, err := misc.ResolveSafeFilePath(authFilePath) + if err != nil { + return fmt.Errorf("base token storage: invalid file path: %w", err) + } + misc.LogSavingCredentials(safePath) + + if err = os.MkdirAll(filepath.Dir(safePath), 0o700); err != nil { + return fmt.Errorf("base token storage: create directory: %w", err) + } + + // Write to a temporary file in the same directory, then rename so that + // a concurrent reader never observes a partially-written file. + tmpFile, err := os.CreateTemp(filepath.Dir(safePath), ".tmp-token-*") + if err != nil { + return fmt.Errorf("base token storage: create temp file: %w", err) + } + tmpPath := tmpFile.Name() + + writeErr := json.NewEncoder(tmpFile).Encode(v) + closeErr := tmpFile.Close() + + if writeErr != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("base token storage: encode token: %w", writeErr) + } + if closeErr != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("base token storage: close temp file: %w", closeErr) + } + + if err = os.Rename(tmpPath, safePath); err != nil { + _ = os.Remove(tmpPath) + return fmt.Errorf("base token storage: rename temp file: %w", err) + } + return nil +} + +// Load reads the JSON file at authFilePath and unmarshals it into v. +// v should be a pointer to the outer provider struct so that all fields +// are populated. +func (b *BaseTokenStorage) Load(authFilePath string, v any) error { + safePath, err := misc.ResolveSafeFilePath(authFilePath) + if err != nil { + return fmt.Errorf("base token storage: invalid file path: %w", err) + } + + data, err := os.ReadFile(safePath) + if err != nil { + return fmt.Errorf("base token storage: read token file: %w", err) + } + + if err = json.Unmarshal(data, v); err != nil { + return fmt.Errorf("base token storage: unmarshal token: %w", err) + } + return nil +} + +// Clear removes the token file at authFilePath. It returns nil if the file +// does not exist (idempotent delete). +func (b *BaseTokenStorage) Clear(authFilePath string) error { + safePath, err := misc.ResolveSafeFilePath(authFilePath) + if err != nil { + return fmt.Errorf("base token storage: invalid file path: %w", err) + } + + if err = os.Remove(safePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("base token storage: remove token file: %w", err) + } + return nil +} diff --git a/pkg/llmproxy/auth/claude/anthropic_auth.go b/pkg/llmproxy/auth/claude/anthropic_auth.go index 78a889ff3c..ec06454aa1 100644 --- a/pkg/llmproxy/auth/claude/anthropic_auth.go +++ b/pkg/llmproxy/auth/claude/anthropic_auth.go @@ -13,7 +13,8 @@ import ( "strings" "time" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" log "github.com/sirupsen/logrus" ) @@ -293,11 +294,13 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C // - *ClaudeTokenStorage: A new token storage instance func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage { storage := &ClaudeTokenStorage{ - AccessToken: bundle.TokenData.AccessToken, - RefreshToken: bundle.TokenData.RefreshToken, - LastRefresh: bundle.LastRefresh, - Email: bundle.TokenData.Email, - Expire: bundle.TokenData.Expire, + BaseTokenStorage: base.BaseTokenStorage{ + AccessToken: bundle.TokenData.AccessToken, + RefreshToken: bundle.TokenData.RefreshToken, + Email: bundle.TokenData.Email, + }, + LastRefresh: bundle.LastRefresh, + Expire: bundle.TokenData.Expire, } return storage diff --git a/pkg/llmproxy/auth/claude/token.go b/pkg/llmproxy/auth/claude/token.go index 550331ed82..22bf50cbda 100644 --- a/pkg/llmproxy/auth/claude/token.go +++ b/pkg/llmproxy/auth/claude/token.go @@ -4,58 +4,23 @@ package claude import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "strings" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" ) -func sanitizeTokenFilePath(authFilePath string) (string, error) { - trimmed := strings.TrimSpace(authFilePath) - if trimmed == "" { - return "", fmt.Errorf("token file path is empty") - } - cleaned := filepath.Clean(trimmed) - parts := strings.FieldsFunc(cleaned, func(r rune) bool { - return r == '/' || r == '\\' - }) - for _, part := range parts { - if part == ".." { - return "", fmt.Errorf("invalid token file path") - } - } - absPath, err := filepath.Abs(cleaned) - if err != nil { - return "", fmt.Errorf("failed to resolve token file path: %w", err) - } - return absPath, nil -} - // ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication. // It maintains compatibility with the existing auth system while adding Claude-specific fields // for managing access tokens, refresh tokens, and user account information. type ClaudeTokenStorage struct { + base.BaseTokenStorage + // IDToken is the JWT ID token containing user claims and identity information. IDToken string `json:"id_token"` - // AccessToken is the OAuth2 access token used for authenticating API requests. - AccessToken string `json:"access_token"` - - // RefreshToken is used to obtain new access tokens when the current one expires. - RefreshToken string `json:"refresh_token"` - // LastRefresh is the timestamp of the last token refresh operation. LastRefresh string `json:"last_refresh"` - // Email is the Anthropic account email address associated with this token. - Email string `json:"email"` - - // Type indicates the authentication provider type, always "claude" for this storage. - Type string `json:"type"` - // Expire is the timestamp when the current access token expires. Expire string `json:"expired"` } @@ -70,34 +35,9 @@ type ClaudeTokenStorage struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error { - safePath, err := misc.ResolveSafeFilePath(authFilePath) - if err != nil { - return fmt.Errorf("invalid token file path: %w", err) - } - misc.LogSavingCredentials(safePath) ts.Type = "claude" - safePath, err = sanitizeTokenFilePath(authFilePath) - if err != nil { - return err - } - - // Create directory structure if it doesn't exist - if err = os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return fmt.Errorf("failed to create directory: %v", err) - } - - // Create the token file - f, err := os.Create(safePath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) - } - defer func() { - _ = f.Close() - }() - - // Encode and write the token data as JSON - if err = json.NewEncoder(f).Encode(ts); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) + if err := ts.Save(authFilePath, ts); err != nil { + return fmt.Errorf("claude token: %w", err) } return nil } diff --git a/pkg/llmproxy/auth/claude/utls_transport.go b/pkg/llmproxy/auth/claude/utls_transport.go index 5d1f7f1660..1f8f2c900b 100644 --- a/pkg/llmproxy/auth/claude/utls_transport.go +++ b/pkg/llmproxy/auth/claude/utls_transport.go @@ -8,8 +8,8 @@ import ( "strings" "sync" + pkgconfig "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" tls "github.com/refraction-networking/utls" - pkgconfig "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" log "github.com/sirupsen/logrus" "golang.org/x/net/http2" "golang.org/x/net/proxy" diff --git a/pkg/llmproxy/auth/codex/openai_auth.go b/pkg/llmproxy/auth/codex/openai_auth.go index 9652caeba6..3adc4e469e 100644 --- a/pkg/llmproxy/auth/codex/openai_auth.go +++ b/pkg/llmproxy/auth/codex/openai_auth.go @@ -14,7 +14,8 @@ import ( "strings" "time" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" log "github.com/sirupsen/logrus" ) @@ -257,13 +258,15 @@ func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*Co // It populates the storage struct with token data, user information, and timestamps. func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage { storage := &CodexTokenStorage{ - IDToken: bundle.TokenData.IDToken, - AccessToken: bundle.TokenData.AccessToken, - RefreshToken: bundle.TokenData.RefreshToken, - AccountID: bundle.TokenData.AccountID, - LastRefresh: bundle.LastRefresh, - Email: bundle.TokenData.Email, - Expire: bundle.TokenData.Expire, + BaseTokenStorage: base.BaseTokenStorage{ + AccessToken: bundle.TokenData.AccessToken, + RefreshToken: bundle.TokenData.RefreshToken, + Email: bundle.TokenData.Email, + }, + IDToken: bundle.TokenData.IDToken, + AccountID: bundle.TokenData.AccountID, + LastRefresh: bundle.LastRefresh, + Expire: bundle.TokenData.Expire, } return storage diff --git a/pkg/llmproxy/auth/codex/token.go b/pkg/llmproxy/auth/codex/token.go index 4e8c3fb2ac..297ffdd003 100644 --- a/pkg/llmproxy/auth/codex/token.go +++ b/pkg/llmproxy/auth/codex/token.go @@ -4,54 +4,23 @@ package codex import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "strings" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" ) -func sanitizeTokenFilePath(authFilePath string) (string, error) { - trimmed := strings.TrimSpace(authFilePath) - if trimmed == "" { - return "", fmt.Errorf("token file path is empty") - } - cleaned := filepath.Clean(trimmed) - parts := strings.FieldsFunc(cleaned, func(r rune) bool { - return r == '/' || r == '\\' - }) - for _, part := range parts { - if part == ".." { - return "", fmt.Errorf("invalid token file path") - } - } - absPath, err := filepath.Abs(cleaned) - if err != nil { - return "", fmt.Errorf("failed to resolve token file path: %w", err) - } - return absPath, nil -} - // CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication. // It maintains compatibility with the existing auth system while adding Codex-specific fields // for managing access tokens, refresh tokens, and user account information. type CodexTokenStorage struct { + base.BaseTokenStorage + // IDToken is the JWT ID token containing user claims and identity information. IDToken string `json:"id_token"` - // AccessToken is the OAuth2 access token used for authenticating API requests. - AccessToken string `json:"access_token"` - // RefreshToken is used to obtain new access tokens when the current one expires. - RefreshToken string `json:"refresh_token"` // AccountID is the OpenAI account identifier associated with this token. AccountID string `json:"account_id"` // LastRefresh is the timestamp of the last token refresh operation. LastRefresh string `json:"last_refresh"` - // Email is the OpenAI account email address associated with this token. - Email string `json:"email"` - // Type indicates the authentication provider type, always "codex" for this storage. - Type string `json:"type"` // Expire is the timestamp when the current access token expires. Expire string `json:"expired"` } @@ -66,27 +35,9 @@ type CodexTokenStorage struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error { - safePath, err := misc.ResolveSafeFilePath(authFilePath) - if err != nil { - return fmt.Errorf("invalid token file path: %w", err) - } - misc.LogSavingCredentials(safePath) ts.Type = "codex" - if err = os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return fmt.Errorf("failed to create directory: %v", err) - } - - f, err := os.Create(safePath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) - } - defer func() { - _ = f.Close() - }() - - if err = json.NewEncoder(f).Encode(ts); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) + if err := ts.Save(authFilePath, ts); err != nil { + return fmt.Errorf("codex token: %w", err) } return nil - } diff --git a/pkg/llmproxy/auth/copilot/copilot_auth.go b/pkg/llmproxy/auth/copilot/copilot_auth.go index 0fac429994..bff26bece4 100644 --- a/pkg/llmproxy/auth/copilot/copilot_auth.go +++ b/pkg/llmproxy/auth/copilot/copilot_auth.go @@ -10,7 +10,8 @@ import ( "net/http" "time" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" log "github.com/sirupsen/logrus" ) @@ -164,11 +165,13 @@ func (c *CopilotAuth) ValidateToken(ctx context.Context, accessToken string) (bo // CreateTokenStorage creates a new CopilotTokenStorage from auth bundle. func (c *CopilotAuth) CreateTokenStorage(bundle *CopilotAuthBundle) *CopilotTokenStorage { return &CopilotTokenStorage{ - AccessToken: bundle.TokenData.AccessToken, - TokenType: bundle.TokenData.TokenType, - Scope: bundle.TokenData.Scope, - Username: bundle.Username, - Type: "github-copilot", + BaseTokenStorage: base.BaseTokenStorage{ + AccessToken: bundle.TokenData.AccessToken, + Type: "github-copilot", + }, + TokenType: bundle.TokenData.TokenType, + Scope: bundle.TokenData.Scope, + Username: bundle.Username, } } diff --git a/pkg/llmproxy/auth/copilot/token.go b/pkg/llmproxy/auth/copilot/token.go index 657428a982..cecb2e4441 100644 --- a/pkg/llmproxy/auth/copilot/token.go +++ b/pkg/llmproxy/auth/copilot/token.go @@ -4,20 +4,17 @@ package copilot import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" ) // CopilotTokenStorage stores OAuth2 token information for GitHub Copilot API authentication. // It maintains compatibility with the existing auth system while adding Copilot-specific fields // for managing access tokens and user account information. type CopilotTokenStorage struct { - // AccessToken is the OAuth2 access token used for authenticating API requests. - AccessToken string `json:"access_token"` + base.BaseTokenStorage + // TokenType is the type of token, typically "bearer". TokenType string `json:"token_type"` // Scope is the OAuth2 scope granted to the token. @@ -26,8 +23,6 @@ type CopilotTokenStorage struct { ExpiresAt string `json:"expires_at,omitempty"` // Username is the GitHub username associated with this token. Username string `json:"username"` - // Type indicates the authentication provider type, always "github-copilot" for this storage. - Type string `json:"type"` } // CopilotTokenData holds the raw OAuth token response from GitHub. @@ -72,26 +67,9 @@ type DeviceCodeResponse struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *CopilotTokenStorage) SaveTokenToFile(authFilePath string) error { - safePath, err := misc.ResolveSafeFilePath(authFilePath) - if err != nil { - return fmt.Errorf("invalid token file path: %w", err) - } - misc.LogSavingCredentials(safePath) ts.Type = "github-copilot" - if err = os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return fmt.Errorf("failed to create directory: %v", err) - } - - f, err := os.Create(safePath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) - } - defer func() { - _ = f.Close() - }() - - if err = json.NewEncoder(f).Encode(ts); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) + if err := ts.Save(authFilePath, ts); err != nil { + return fmt.Errorf("copilot token: %w", err) } return nil } diff --git a/pkg/llmproxy/auth/gemini/gemini_auth.go b/pkg/llmproxy/auth/gemini/gemini_auth.go index 6553cd26d2..08badb1283 100644 --- a/pkg/llmproxy/auth/gemini/gemini_auth.go +++ b/pkg/llmproxy/auth/gemini/gemini_auth.go @@ -14,9 +14,10 @@ import ( "net/url" "time" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/codex" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/browser" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" log "github.com/sirupsen/logrus" @@ -204,9 +205,11 @@ func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Conf ifToken["universe_domain"] = "googleapis.com" ts := GeminiTokenStorage{ + BaseTokenStorage: base.BaseTokenStorage{ + Email: emailResult.String(), + }, Token: ifToken, ProjectID: projectID, - Email: emailResult.String(), } return &ts, nil diff --git a/pkg/llmproxy/auth/gemini/gemini_token.go b/pkg/llmproxy/auth/gemini/gemini_token.go index 1fb90dae57..32b05729a1 100644 --- a/pkg/llmproxy/auth/gemini/gemini_token.go +++ b/pkg/llmproxy/auth/gemini/gemini_token.go @@ -4,37 +4,33 @@ package gemini import ( - "encoding/json" "fmt" - "os" - "path/filepath" "strings" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" - log "github.com/sirupsen/logrus" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" ) // GeminiTokenStorage stores OAuth2 token information for Google Gemini API authentication. // It maintains compatibility with the existing auth system while adding Gemini-specific fields // for managing access tokens, refresh tokens, and user account information. +// +// Note: Gemini wraps its raw OAuth2 token inside the Token field (type any) rather than +// storing access/refresh tokens as top-level strings, so BaseTokenStorage.AccessToken and +// BaseTokenStorage.RefreshToken remain empty for this provider. type GeminiTokenStorage struct { + base.BaseTokenStorage + // Token holds the raw OAuth2 token data, including access and refresh tokens. Token any `json:"token"` // ProjectID is the Google Cloud Project ID associated with this token. ProjectID string `json:"project_id"` - // Email is the email address of the authenticated user. - Email string `json:"email"` - // Auto indicates if the project ID was automatically selected. Auto bool `json:"auto"` // Checked indicates if the associated Cloud AI API has been verified as enabled. Checked bool `json:"checked"` - - // Type indicates the authentication provider type, always "gemini" for this storage. - Type string `json:"type"` } // SaveTokenToFile serializes the Gemini token storage to a JSON file. @@ -47,28 +43,9 @@ type GeminiTokenStorage struct { // Returns: // - error: An error if the operation fails, nil otherwise func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error { - safePath, err := misc.ResolveSafeFilePath(authFilePath) - if err != nil { - return fmt.Errorf("invalid token file path: %w", err) - } - misc.LogSavingCredentials(safePath) ts.Type = "gemini" - if err = os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return fmt.Errorf("failed to create directory: %v", err) - } - - f, err := os.Create(safePath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) - } - defer func() { - if errClose := f.Close(); errClose != nil { - log.Errorf("failed to close file: %v", errClose) - } - }() - - if err = json.NewEncoder(f).Encode(ts); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) + if err := ts.Save(authFilePath, ts); err != nil { + return fmt.Errorf("gemini token: %w", err) } return nil } diff --git a/pkg/llmproxy/auth/iflow/iflow_auth.go b/pkg/llmproxy/auth/iflow/iflow_auth.go index d1c8fe26b2..a4ead0e04c 100644 --- a/pkg/llmproxy/auth/iflow/iflow_auth.go +++ b/pkg/llmproxy/auth/iflow/iflow_auth.go @@ -13,7 +13,8 @@ import ( "strings" "time" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" log "github.com/sirupsen/logrus" ) @@ -243,14 +244,16 @@ func (ia *IFlowAuth) CreateTokenStorage(data *IFlowTokenData) *IFlowTokenStorage return nil } return &IFlowTokenStorage{ - AccessToken: data.AccessToken, - RefreshToken: data.RefreshToken, - LastRefresh: time.Now().Format(time.RFC3339), - Expire: data.Expire, - APIKey: data.APIKey, - Email: data.Email, - TokenType: data.TokenType, - Scope: data.Scope, + BaseTokenStorage: base.BaseTokenStorage{ + AccessToken: data.AccessToken, + RefreshToken: data.RefreshToken, + Email: data.Email, + }, + LastRefresh: time.Now().Format(time.RFC3339), + Expire: data.Expire, + APIKey: data.APIKey, + TokenType: data.TokenType, + Scope: data.Scope, } } @@ -528,12 +531,14 @@ func (ia *IFlowAuth) CreateCookieTokenStorage(data *IFlowTokenData) *IFlowTokenS } return &IFlowTokenStorage{ + BaseTokenStorage: base.BaseTokenStorage{ + Email: data.Email, + Type: "iflow", + }, APIKey: data.APIKey, - Email: data.Email, Expire: data.Expire, Cookie: cookieToSave, LastRefresh: time.Now().Format(time.RFC3339), - Type: "iflow", } } diff --git a/pkg/llmproxy/auth/iflow/iflow_token.go b/pkg/llmproxy/auth/iflow/iflow_token.go index b67f7148c0..fb925a2f1a 100644 --- a/pkg/llmproxy/auth/iflow/iflow_token.go +++ b/pkg/llmproxy/auth/iflow/iflow_token.go @@ -1,48 +1,28 @@ package iflow import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" ) // IFlowTokenStorage persists iFlow OAuth credentials alongside the derived API key. type IFlowTokenStorage struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - LastRefresh string `json:"last_refresh"` - Expire string `json:"expired"` - APIKey string `json:"api_key"` - Email string `json:"email"` - TokenType string `json:"token_type"` - Scope string `json:"scope"` - Cookie string `json:"cookie"` - Type string `json:"type"` + base.BaseTokenStorage + + LastRefresh string `json:"last_refresh"` + Expire string `json:"expired"` + APIKey string `json:"api_key"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Cookie string `json:"cookie"` } // SaveTokenToFile serialises the token storage to disk. func (ts *IFlowTokenStorage) SaveTokenToFile(authFilePath string) error { - safePath, err := misc.ResolveSafeFilePath(authFilePath) - if err != nil { - return fmt.Errorf("invalid token file path: %w", err) - } - misc.LogSavingCredentials(safePath) ts.Type = "iflow" - if err = os.MkdirAll(filepath.Dir(safePath), 0o700); err != nil { - return fmt.Errorf("iflow token: create directory failed: %w", err) - } - - f, err := os.Create(safePath) - if err != nil { - return fmt.Errorf("iflow token: create file failed: %w", err) - } - defer func() { _ = f.Close() }() - - if err = json.NewEncoder(f).Encode(ts); err != nil { - return fmt.Errorf("iflow token: encode token failed: %w", err) + if err := ts.Save(authFilePath, ts); err != nil { + return fmt.Errorf("iflow token: %w", err) } return nil } diff --git a/pkg/llmproxy/auth/kilo/kilo_token.go b/pkg/llmproxy/auth/kilo/kilo_token.go index bb09922167..71c17e1dc4 100644 --- a/pkg/llmproxy/auth/kilo/kilo_token.go +++ b/pkg/llmproxy/auth/kilo/kilo_token.go @@ -3,18 +3,20 @@ package kilo import ( - "encoding/json" "fmt" - "os" - "path/filepath" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" - log "github.com/sirupsen/logrus" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" ) // KiloTokenStorage stores token information for Kilo AI authentication. +// +// Note: Kilo uses a proprietary token format stored under the "kilocodeToken" JSON key +// rather than the standard "access_token" key, so BaseTokenStorage.AccessToken is not +// populated for this provider. The Email and Type fields from BaseTokenStorage are used. type KiloTokenStorage struct { - // Token is the Kilo access token. + base.BaseTokenStorage + + // Token is the Kilo access token (serialised as "kilocodeToken" for Kilo compatibility). Token string `json:"kilocodeToken"` // OrganizationID is the Kilo organization ID. @@ -22,38 +24,13 @@ type KiloTokenStorage struct { // Model is the default model to use. Model string `json:"kilocodeModel"` - - // Email is the email address of the authenticated user. - Email string `json:"email"` - - // Type indicates the authentication provider type, always "kilo" for this storage. - Type string `json:"type"` } // SaveTokenToFile serializes the Kilo token storage to a JSON file. func (ts *KiloTokenStorage) SaveTokenToFile(authFilePath string) error { - safePath, err := misc.ResolveSafeFilePath(authFilePath) - if err != nil { - return fmt.Errorf("invalid token file path: %w", err) - } - misc.LogSavingCredentials(safePath) ts.Type = "kilo" - if err = os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return fmt.Errorf("failed to create directory: %v", err) - } - - f, err := os.Create(safePath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) - } - defer func() { - if errClose := f.Close(); errClose != nil { - log.Errorf("failed to close file: %v", errClose) - } - }() - - if err = json.NewEncoder(f).Encode(ts); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) + if err := ts.Save(authFilePath, ts); err != nil { + return fmt.Errorf("kilo token: %w", err) } return nil } diff --git a/pkg/llmproxy/auth/kimi/kimi.go b/pkg/llmproxy/auth/kimi/kimi.go index 42978882b7..2a5ebb6716 100644 --- a/pkg/llmproxy/auth/kimi/kimi.go +++ b/pkg/llmproxy/auth/kimi/kimi.go @@ -15,7 +15,8 @@ import ( "time" "github.com/google/uuid" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" log "github.com/sirupsen/logrus" ) @@ -78,13 +79,15 @@ func (k *KimiAuth) CreateTokenStorage(bundle *KimiAuthBundle) *KimiTokenStorage expired = time.Unix(bundle.TokenData.ExpiresAt, 0).UTC().Format(time.RFC3339) } return &KimiTokenStorage{ - AccessToken: bundle.TokenData.AccessToken, - RefreshToken: bundle.TokenData.RefreshToken, - TokenType: bundle.TokenData.TokenType, - Scope: bundle.TokenData.Scope, - DeviceID: strings.TrimSpace(bundle.DeviceID), - Expired: expired, - Type: "kimi", + BaseTokenStorage: base.BaseTokenStorage{ + AccessToken: bundle.TokenData.AccessToken, + RefreshToken: bundle.TokenData.RefreshToken, + Type: "kimi", + }, + TokenType: bundle.TokenData.TokenType, + Scope: bundle.TokenData.Scope, + DeviceID: strings.TrimSpace(bundle.DeviceID), + Expired: expired, } } diff --git a/pkg/llmproxy/auth/kimi/token.go b/pkg/llmproxy/auth/kimi/token.go index 983cdc306a..61e410c40e 100644 --- a/pkg/llmproxy/auth/kimi/token.go +++ b/pkg/llmproxy/auth/kimi/token.go @@ -4,21 +4,16 @@ package kimi import ( - "encoding/json" "fmt" - "os" - "path/filepath" "time" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/base" ) // KimiTokenStorage stores OAuth2 token information for Kimi API authentication. type KimiTokenStorage 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"` + base.BaseTokenStorage + // TokenType is the type of token, typically "Bearer". TokenType string `json:"token_type"` // Scope is the OAuth2 scope granted to the token. @@ -27,8 +22,6 @@ type KimiTokenStorage struct { DeviceID string `json:"device_id,omitempty"` // Expired is the RFC3339 timestamp when the access token expires. Expired string `json:"expired,omitempty"` - // Type indicates the authentication provider type, always "kimi" for this storage. - Type string `json:"type"` } // KimiTokenData holds the raw OAuth token response from Kimi. @@ -71,29 +64,9 @@ type DeviceCodeResponse struct { // SaveTokenToFile serializes the Kimi token storage to a JSON file. func (ts *KimiTokenStorage) SaveTokenToFile(authFilePath string) error { - safePath, err := misc.ResolveSafeFilePath(authFilePath) - if err != nil { - return fmt.Errorf("invalid token file path: %w", err) - } - misc.LogSavingCredentials(safePath) ts.Type = "kimi" - - if err = os.MkdirAll(filepath.Dir(safePath), 0700); err != nil { - return fmt.Errorf("failed to create directory: %v", err) - } - - f, err := os.Create(safePath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) - } - defer func() { - _ = f.Close() - }() - - encoder := json.NewEncoder(f) - encoder.SetIndent("", " ") - if err = encoder.Encode(ts); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) + if err := ts.Save(authFilePath, ts); err != nil { + return fmt.Errorf("kimi token: %w", err) } return nil }