mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
e8e99b19a8
* Remove dead code: 5 unused symbols across internal packages
- internal/models: LoadModelSettingsFromConfig (zero refs)
- internal/prompts: PromptTemplate.ExpandWithArgs (zero refs)
- internal/app: NewMessageStore (tests migrated to NewMessageStoreWithMessages)
- internal/config: HasEnvVars (+ its test)
- internal/core: ContextWithSudoPassword (test migrated to context.WithValue)
* pkg/kit: use TreeManager alias in exported signatures
NewTreeManagerAdapter and InitTreeSession now spell their signatures with
the public kit.TreeManager alias instead of internal/session.TreeManager,
so go doc renders domain types rather than internal paths.
* Consolidate tool-kind classification into internal/extensions
coreToolKinds + toolKindFor were duplicated verbatim in
internal/extensions/wrapper.go and pkg/kit/events.go, risking silent
divergence between extension events and SDK events. Single source of
truth now lives in internal/extensions/toolkinds.go; pkg/kit re-exports
the constants.
* Consolidate Anthropic OAuth detection and usage-tracker refresh
The 'is the active Anthropic credential a stored OAuth token' check was
copy-pasted at 5 sites, all prefix-matching the magic string
'stored OAuth' produced in internal/auth. Now:
- internal/auth: new CredentialSourceOAuth constant + IsAnthropicOAuth()
- internal/ui: new UpdateUsageTrackerForModel(); CreateUsageTracker and
SetupCLI share lookupTrackableModel (SetupCLI no longer re-inlines the
tracker construction)
- cmd/root.go + cmd/extension_context.go: verbatim-duplicated tracker
refresh blocks replaced with ui.UpdateUsageTrackerForModel
- pkg/kit isAnthropicOAuth delegates to auth.IsAnthropicOAuth
- internal/models compares source against the constant
* pkg/kit: consolidate model-path helpers and argument tokenizer
- ExtractModelFromPath mis-parsed model IDs containing '/' (e.g.
'openrouter/meta/llama' -> 'meta'); it now delegates to
RemoveProviderFromModel and is deprecated alongside
ExtractProviderFromPath (-> GetCurrentProvider)
- parseFields delegated to prompts.ParseCommandArgs so extension argument
parsing and builtin prompt-template parsing share one quote/escape
grammar; ParseCommandArgs now also splits on tabs (superset of both
previous tokenizers)
* Unify the two {{variable}} template engines
internal/skills and pkg/kit/template_bridge each had their own grammar:
skills rejected '{{ name }}' (whitespace) but allowed digit-first names;
the bridge was the opposite. A template behaved differently depending on
whether it was loaded as a skill prompt or via the extension API.
internal/skills is now the single engine using the superset grammar
(\{\{\s*(\w+)\s*\}\}); pkg/kit ParseTemplate/RenderTemplate are thin
adapters over it. Expand is now regex-based so whitespace placeholders
expand consistently; missing variables are still left as-is.
* internal/ui: extract switchModel helper for model-switch flow
The model-selector handler (ModelSelectedMsg) and /model slash command
duplicated the full switch sequence (thinking-level fallback, setModel,
display-state update, preference persistence, ModelChange emit) and had
already drifted in ordering. Both now call a single switchModel method.
Display state is still updated directly (no prog.Send from Update).
* extbridge: extract shared BaseContext for extension wiring
cmd/extension_context.go and internal/acpserver/session.go each built a
giant extensions.Context literal, duplicating ~15 delegation closures
(GetContextStats, GetMessages, AppendEntry, options, SetModel core,
Complete, SpawnSubagent, ...) that had to be kept in sync by hand. New
data-access fields had to be wired in both places or ACP-mode extensions
silently got nil function fields.
extbridge.BaseContext now provides the headless half; both call sites
overlay only their UI-specific closures. As a side effect ACP mode gains
previously-missing APIs (state, tree navigation, skills, template
parsing, model resolution) that were nil before. The interactive TUI
keeps its exact SetModel/ReloadExtensions ordering via overrides.
* internal/tools: extract withOAuthRetry and marshalToolResult helpers
ExecuteTool repeated the OAuth-error/re-auth/retry stanza verbatim twice
(sync and task-augmented paths) and the marshal-and-wrap stanza four
times. Both are now single helpers with identical error strings, so a
fix to OAuth retry or error categorization applies everywhere at once.
* internal/ui: extract buildShareFile with defer-based cleanup
handleShareCommand repeated the close/remove/print/return cleanup chain
four times across its temp-file write error paths. File assembly now
lives in buildShareFile with a single deferred cleanup on error.
* cmd: extract flag validation, preference restore, and provider-URL routing from runNormalMode
runNormalMode opened with ~150 lines of policy logic (flag-combination
validation, persisted model/thinking-level preference restoration, and
two subtle --provider-url model-rewrite rules). These are now standalone
functions (validateModeFlags, restorePersistedPreferences,
applyProviderURLRouting) so the routing policy is independently readable
and testable. Behaviour unchanged; ordering preserved.
* fix: address review findings on SDK godoc and nil guard
- pkg/kit: remove internal package paths from exported godoc on
ParseTemplate and the ToolKind* constants (SDK doc surface must not
reference internal packages)
- internal/tools: guard marshalToolResult against a nil CallToolResult
(json.Marshal(nil) succeeds as 'null', then result.IsError panics if
a client returns nil result with nil error)
Skipped the TreeNode Children deep-copy suggestion: the slice already
comes from TreeManager.GetChildren which returns a fresh copy per call
into a throwaway intermediate, so no internal state is exposed.
574 lines
20 KiB
Go
574 lines
20 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CredentialStore holds stored credentials for Anthropic, OpenAI, and GitHub Copilot.
|
|
type CredentialStore struct {
|
|
Anthropic *AnthropicCredentials `json:"anthropic,omitempty"`
|
|
OpenAI *OpenAICredentials `json:"openai,omitempty"`
|
|
Copilot *CopilotCredentials `json:"copilot,omitempty"`
|
|
}
|
|
|
|
// AnthropicCredentials holds Anthropic API credentials supporting both OAuth
|
|
// and API key authentication methods. The Type field indicates which authentication
|
|
// method is being used. For OAuth, tokens are stored with expiration timestamps
|
|
// for automatic refresh. For API keys, only the key itself is stored.
|
|
type AnthropicCredentials struct {
|
|
Type string `json:"type"` // "oauth" or "api_key"
|
|
APIKey string `json:"api_key,omitempty"` // For API key auth
|
|
AccessToken string `json:"access_token,omitempty"` // For OAuth
|
|
RefreshToken string `json:"refresh_token,omitempty"` // For OAuth
|
|
ExpiresAt int64 `json:"expires_at,omitempty"` // For OAuth
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// OpenAICredentials holds OpenAI API credentials supporting both OAuth
|
|
// and API key authentication methods. The Type field indicates which authentication
|
|
// method is being used. For OAuth, tokens are stored with expiration timestamps
|
|
// for automatic refresh. For API keys, only the key itself is stored.
|
|
type OpenAICredentials struct {
|
|
Type string `json:"type"` // "oauth" or "api_key"
|
|
APIKey string `json:"api_key,omitempty"` // For API key auth
|
|
AccessToken string `json:"access_token,omitempty"` // For OAuth
|
|
RefreshToken string `json:"refresh_token,omitempty"` // For OAuth
|
|
ExpiresAt int64 `json:"expires_at,omitempty"` // For OAuth
|
|
AccountID string `json:"account_id,omitempty"` // For OAuth (ChatGPT account ID)
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// CopilotCredentials holds GitHub OAuth credentials and the short-lived
|
|
// GitHub Copilot API token derived from them.
|
|
type CopilotCredentials struct {
|
|
Type string `json:"type"` // "oauth"
|
|
GitHubToken string `json:"github_token,omitempty"` // GitHub device-flow OAuth token
|
|
CopilotAccessToken string `json:"copilot_access_token,omitempty"` // Short-lived Copilot API token
|
|
ExpiresAt int64 `json:"expires_at,omitempty"` // Copilot token expiry
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// oauthTokenExpired reports whether an OAuth token with the given type and
|
|
// expiry unix timestamp is past its expiry. Returns false for API key
|
|
// credentials or when no expiry is set.
|
|
func oauthTokenExpired(credType string, expiresAt int64) bool {
|
|
if credType != "oauth" || expiresAt == 0 {
|
|
return false
|
|
}
|
|
return time.Now().Unix() >= expiresAt
|
|
}
|
|
|
|
// oauthTokenNeedsRefresh reports whether an OAuth token will expire within the
|
|
// next 5 minutes, allowing proactive refresh before it becomes invalid.
|
|
// Returns false for API key credentials or when no expiry is set.
|
|
func oauthTokenNeedsRefresh(credType string, expiresAt int64) bool {
|
|
if credType != "oauth" || expiresAt == 0 {
|
|
return false
|
|
}
|
|
return time.Now().Unix() >= (expiresAt - 300) // 5 minutes buffer
|
|
}
|
|
|
|
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
|
|
// Returns false for API key authentication or if no expiration is set.
|
|
func (c *AnthropicCredentials) IsExpired() bool {
|
|
return oauthTokenExpired(c.Type, c.ExpiresAt)
|
|
}
|
|
|
|
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
|
|
// will expire within the next 5 minutes. This allows for proactive token refresh
|
|
// to avoid authentication failures during operations. Returns false for API key
|
|
// authentication or if no expiration is set.
|
|
func (c *AnthropicCredentials) NeedsRefresh() bool {
|
|
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
|
|
}
|
|
|
|
// IsExpired checks if the OAuth token is expired based on the ExpiresAt timestamp.
|
|
// Returns false for API key authentication or if no expiration is set.
|
|
func (c *OpenAICredentials) IsExpired() bool {
|
|
return oauthTokenExpired(c.Type, c.ExpiresAt)
|
|
}
|
|
|
|
// NeedsRefresh checks if the OAuth token needs refresh, returning true if the token
|
|
// will expire within the next 5 minutes. This allows for proactive token refresh
|
|
// to avoid authentication failures during operations. Returns false for API key
|
|
// authentication or if no expiration is set.
|
|
func (c *OpenAICredentials) NeedsRefresh() bool {
|
|
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
|
|
}
|
|
|
|
// IsExpired checks if the Copilot API token is expired.
|
|
func (c *CopilotCredentials) IsExpired() bool {
|
|
return oauthTokenExpired(c.Type, c.ExpiresAt)
|
|
}
|
|
|
|
// NeedsRefresh reports whether the Copilot API token should be renewed.
|
|
func (c *CopilotCredentials) NeedsRefresh() bool {
|
|
return oauthTokenNeedsRefresh(c.Type, c.ExpiresAt)
|
|
}
|
|
|
|
// CredentialManager handles secure storage and retrieval of authentication credentials.
|
|
// It manages a JSON file stored in the user's config directory with appropriate
|
|
// file permissions for security.
|
|
type CredentialManager struct {
|
|
credentialsPath string
|
|
}
|
|
|
|
// NewCredentialManager creates a new credential manager instance. It determines
|
|
// the appropriate credentials path based on XDG_CONFIG_HOME or falls back to
|
|
// ~/.config/.kit/credentials.json. Returns an error if the home directory
|
|
// cannot be determined.
|
|
func NewCredentialManager() (*CredentialManager, error) {
|
|
credentialsPath, err := getCredentialsPath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to determine credentials path: %w", err)
|
|
}
|
|
|
|
return &CredentialManager{
|
|
credentialsPath: credentialsPath,
|
|
}, nil
|
|
}
|
|
|
|
// getCredentialsPath returns the path to the credentials file
|
|
func getCredentialsPath() (string, error) {
|
|
// Try XDG_CONFIG_HOME first
|
|
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
|
|
return filepath.Join(xdgConfig, ".kit", "credentials.json"), nil
|
|
}
|
|
|
|
// Fall back to ~/.config/.kit/credentials.json
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get home directory: %w", err)
|
|
}
|
|
|
|
return filepath.Join(homeDir, ".config", ".kit", "credentials.json"), nil
|
|
}
|
|
|
|
// LoadCredentials loads credentials from the JSON file. If the file doesn't exist,
|
|
// it returns an empty CredentialStore instead of an error, allowing for graceful
|
|
// initialization. Returns an error if the file exists but cannot be read or parsed.
|
|
func (cm *CredentialManager) LoadCredentials() (*CredentialStore, error) {
|
|
// If file doesn't exist, return empty store
|
|
if _, err := os.Stat(cm.credentialsPath); os.IsNotExist(err) {
|
|
return &CredentialStore{}, nil
|
|
}
|
|
|
|
data, err := os.ReadFile(cm.credentialsPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read credentials file: %w", err)
|
|
}
|
|
|
|
var store CredentialStore
|
|
if err := json.Unmarshal(data, &store); err != nil {
|
|
return nil, fmt.Errorf("failed to parse credentials file: %w", err)
|
|
}
|
|
|
|
return &store, nil
|
|
}
|
|
|
|
// SaveCredentials saves credentials to the JSON file with secure permissions (0600).
|
|
// It creates the parent directory if it doesn't exist. The file is written atomically
|
|
// to prevent corruption. Returns an error if the directory cannot be created or the
|
|
// file cannot be written.
|
|
func (cm *CredentialManager) SaveCredentials(store *CredentialStore) error {
|
|
// Ensure directory exists
|
|
dir := filepath.Dir(cm.credentialsPath)
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return fmt.Errorf("failed to create credentials directory: %w", err)
|
|
}
|
|
|
|
data, err := json.MarshalIndent(store, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal credentials: %w", err)
|
|
}
|
|
|
|
// Write with restrictive permissions (read/write for owner only)
|
|
if err := os.WriteFile(cm.credentialsPath, data, 0600); err != nil {
|
|
return fmt.Errorf("failed to write credentials file: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetAnthropicCredentials stores Anthropic API key credentials. It validates the
|
|
// API key format before storing. The API key must start with "sk-ant-" and be
|
|
// at least 20 characters long. Returns an error if the API key is invalid or
|
|
// if storage fails.
|
|
func (cm *CredentialManager) SetAnthropicCredentials(apiKey string) error {
|
|
if err := validateAnthropicAPIKey(apiKey); err != nil {
|
|
return err
|
|
}
|
|
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store.Anthropic = &AnthropicCredentials{
|
|
Type: "api_key",
|
|
APIKey: apiKey,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
return cm.SaveCredentials(store)
|
|
}
|
|
|
|
// GetAnthropicCredentials retrieves stored Anthropic credentials. Returns nil if
|
|
// no credentials are stored. The returned credentials may be either OAuth or API
|
|
// key type, check the Type field to determine which.
|
|
func (cm *CredentialManager) GetAnthropicCredentials() (*AnthropicCredentials, error) {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return store.Anthropic, nil
|
|
}
|
|
|
|
// RemoveAnthropicCredentials removes stored Anthropic credentials from storage.
|
|
// If this was the only credential stored, the entire credentials file is removed.
|
|
// Returns an error if the removal fails.
|
|
func (cm *CredentialManager) RemoveAnthropicCredentials() error {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store.Anthropic = nil
|
|
|
|
// If store is empty, remove the file entirely
|
|
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
|
|
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to remove credentials file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return cm.SaveCredentials(store)
|
|
}
|
|
|
|
// HasAnthropicCredentials checks if valid Anthropic credentials are stored.
|
|
// Returns true if either a non-empty OAuth access token or API key is present,
|
|
// false otherwise. Returns an error if credentials cannot be loaded.
|
|
func (cm *CredentialManager) HasAnthropicCredentials() (bool, error) {
|
|
creds, err := cm.GetAnthropicCredentials()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if creds == nil {
|
|
return false, nil
|
|
}
|
|
|
|
// Check based on credential type
|
|
switch creds.Type {
|
|
case "oauth":
|
|
return creds.AccessToken != "", nil
|
|
case "api_key":
|
|
return creds.APIKey != "", nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// GetOpenAICredentials retrieves stored OpenAI credentials. Returns nil if
|
|
// no credentials are stored. The returned credentials may be either OAuth or API
|
|
// key type, check the Type field to determine which.
|
|
func (cm *CredentialManager) GetOpenAICredentials() (*OpenAICredentials, error) {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return store.OpenAI, nil
|
|
}
|
|
|
|
// RemoveOpenAICredentials removes stored OpenAI credentials from storage.
|
|
// If this was the only credential stored, the entire credentials file is removed.
|
|
// Returns an error if the removal fails.
|
|
func (cm *CredentialManager) RemoveOpenAICredentials() error {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store.OpenAI = nil
|
|
|
|
// If store is empty, remove the file entirely
|
|
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
|
|
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to remove credentials file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return cm.SaveCredentials(store)
|
|
}
|
|
|
|
// GetCopilotCredentials retrieves stored GitHub Copilot credentials.
|
|
func (cm *CredentialManager) GetCopilotCredentials() (*CopilotCredentials, error) {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return store.Copilot, nil
|
|
}
|
|
|
|
// RemoveCopilotCredentials removes stored GitHub Copilot credentials.
|
|
func (cm *CredentialManager) RemoveCopilotCredentials() error {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store.Copilot = nil
|
|
|
|
if store.Anthropic == nil && store.OpenAI == nil && store.Copilot == nil {
|
|
if err := os.Remove(cm.credentialsPath); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("failed to remove credentials file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
return cm.SaveCredentials(store)
|
|
}
|
|
|
|
// HasCopilotCredentials checks if valid GitHub Copilot credentials are stored.
|
|
func (cm *CredentialManager) HasCopilotCredentials() (bool, error) {
|
|
creds, err := cm.GetCopilotCredentials()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if creds == nil {
|
|
return false, nil
|
|
}
|
|
|
|
return creds.Type == "oauth" && creds.GitHubToken != "", nil
|
|
}
|
|
|
|
// SetCopilotOAuthCredentials stores GitHub Copilot OAuth credentials.
|
|
func (cm *CredentialManager) SetCopilotOAuthCredentials(creds *CopilotCredentials) error {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store.Copilot = creds
|
|
return cm.SaveCredentials(store)
|
|
}
|
|
|
|
// GetValidCopilotAccessToken returns a fresh Copilot API token, renewing it
|
|
// with the stored GitHub OAuth token when needed.
|
|
func (cm *CredentialManager) GetValidCopilotAccessToken() (string, error) {
|
|
return cm.GetValidCopilotAccessTokenContext(context.Background())
|
|
}
|
|
|
|
// GetValidCopilotAccessTokenContext returns a fresh Copilot API token, renewing
|
|
// it with the stored GitHub OAuth token when needed.
|
|
func (cm *CredentialManager) GetValidCopilotAccessTokenContext(ctx context.Context) (string, error) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
creds, err := cm.GetCopilotCredentials()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if creds == nil {
|
|
return "", fmt.Errorf("no Copilot credentials found")
|
|
}
|
|
if creds.Type != "oauth" {
|
|
return "", fmt.Errorf("unknown credential type: %s", creds.Type)
|
|
}
|
|
if creds.GitHubToken == "" {
|
|
return "", fmt.Errorf("GitHub OAuth token missing from Copilot credentials")
|
|
}
|
|
|
|
if creds.CopilotAccessToken == "" || creds.NeedsRefresh() {
|
|
client := NewCopilotOAuthClient()
|
|
newCreds, err := client.RefreshCopilotToken(ctx, creds.GitHubToken)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to refresh Copilot token: %w", err)
|
|
}
|
|
newCreds.CreatedAt = creds.CreatedAt
|
|
|
|
if err := cm.SetCopilotOAuthCredentials(newCreds); err != nil {
|
|
return "", fmt.Errorf("failed to save refreshed Copilot token: %w", err)
|
|
}
|
|
|
|
return newCreds.CopilotAccessToken, nil
|
|
}
|
|
|
|
return creds.CopilotAccessToken, nil
|
|
}
|
|
|
|
// HasOpenAICredentials checks if valid OpenAI credentials are stored.
|
|
// Returns true if either a non-empty OAuth access token or API key is present,
|
|
// false otherwise. Returns an error if credentials cannot be loaded.
|
|
func (cm *CredentialManager) HasOpenAICredentials() (bool, error) {
|
|
creds, err := cm.GetOpenAICredentials()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if creds == nil {
|
|
return false, nil
|
|
}
|
|
|
|
// Check based on credential type
|
|
switch creds.Type {
|
|
case "oauth":
|
|
return creds.AccessToken != "", nil
|
|
case "api_key":
|
|
return creds.APIKey != "", nil
|
|
default:
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
// SetOpenAIOAuthCredentials stores OpenAI OAuth credentials in the credential manager's secure storage.
|
|
// The credentials should include access token, refresh token, and expiration information.
|
|
// Returns an error if the credentials cannot be saved.
|
|
func (cm *CredentialManager) SetOpenAIOAuthCredentials(creds *OpenAICredentials) error {
|
|
store, err := cm.LoadCredentials()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
store.OpenAI = creds
|
|
return cm.SaveCredentials(store)
|
|
}
|
|
|
|
// GetValidOpenAIAccessToken returns a valid access token for API requests. For OAuth credentials,
|
|
// it automatically refreshes the token if it's expired or about to expire. For API key
|
|
// credentials, it simply returns the API key. Returns an error if no credentials are found,
|
|
// if token refresh fails, or if the credential type is unknown.
|
|
func (cm *CredentialManager) GetValidOpenAIAccessToken() (string, error) {
|
|
creds, err := cm.GetOpenAICredentials()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if creds == nil {
|
|
return "", fmt.Errorf("no credentials found")
|
|
}
|
|
|
|
// For API key auth, return the API key
|
|
if creds.Type == "api_key" {
|
|
return creds.APIKey, nil
|
|
}
|
|
|
|
// For OAuth, check if token needs refresh
|
|
if creds.Type == "oauth" {
|
|
if creds.NeedsRefresh() {
|
|
// Refresh the token
|
|
client := NewOpenAIOAuthClient()
|
|
newCreds, err := client.RefreshToken(creds.RefreshToken)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to refresh token: %w", err)
|
|
}
|
|
|
|
// Update stored credentials
|
|
if err := cm.SetOpenAIOAuthCredentials(newCreds); err != nil {
|
|
return "", fmt.Errorf("failed to save refreshed token: %w", err)
|
|
}
|
|
|
|
return newCreds.AccessToken, nil
|
|
}
|
|
|
|
return creds.AccessToken, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("unknown credential type: %s", creds.Type)
|
|
}
|
|
|
|
// GetCredentialsPath returns the absolute path to the credentials JSON file.
|
|
// This is useful for debugging or displaying the storage location to users.
|
|
func (cm *CredentialManager) GetCredentialsPath() string {
|
|
return cm.credentialsPath
|
|
}
|
|
|
|
// validateAnthropicAPIKey validates the format of an Anthropic API key
|
|
func validateAnthropicAPIKey(apiKey string) error {
|
|
apiKey = strings.TrimSpace(apiKey)
|
|
|
|
if apiKey == "" {
|
|
return fmt.Errorf("API key cannot be empty")
|
|
}
|
|
|
|
// Anthropic API keys typically start with "sk-ant-" and are quite long
|
|
if !strings.HasPrefix(apiKey, "sk-ant-") {
|
|
return fmt.Errorf("invalid Anthropic API key format (should start with 'sk-ant-')")
|
|
}
|
|
|
|
if len(apiKey) < 20 {
|
|
return fmt.Errorf("API key appears to be too short")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CredentialSourceOAuth is the source description returned by
|
|
// GetAnthropicAPIKey when the key resolves to stored OAuth credentials.
|
|
// Consumers should compare against this constant (or use IsAnthropicOAuth)
|
|
// rather than matching the string literal.
|
|
const CredentialSourceOAuth = "stored OAuth credentials"
|
|
|
|
// IsAnthropicOAuth reports whether the active Anthropic credential resolves
|
|
// to a stored OAuth token (in which case the user is not billed per-token).
|
|
// flagValue is the --provider-api-key flag value (may be empty).
|
|
func IsAnthropicOAuth(flagValue string) bool {
|
|
_, source, err := GetAnthropicAPIKey(flagValue)
|
|
return err == nil && source == CredentialSourceOAuth
|
|
}
|
|
|
|
// GetAnthropicAPIKey retrieves an Anthropic API key from multiple sources in priority order:
|
|
// 1. Command-line flag value (highest priority)
|
|
// 2. Stored credentials (OAuth or API key)
|
|
// 3. ANTHROPIC_API_KEY environment variable (lowest priority)
|
|
// Returns the API key, a description of its source, and any error encountered.
|
|
// For OAuth credentials, it automatically refreshes expired tokens.
|
|
func GetAnthropicAPIKey(flagValue string) (string, string, error) {
|
|
// 1. Check flag value first (highest priority)
|
|
if flagValue != "" {
|
|
return flagValue, "command-line flag", nil
|
|
}
|
|
|
|
// 2. Check stored credentials
|
|
cm, err := NewCredentialManager()
|
|
if err == nil {
|
|
if creds, err := cm.GetAnthropicCredentials(); err == nil && creds != nil {
|
|
if creds.Type == "oauth" && creds.AccessToken != "" {
|
|
// For OAuth, get a valid access token (may refresh if needed)
|
|
token, err := cm.GetValidAccessToken()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to get valid OAuth token: %w", err)
|
|
}
|
|
return token, CredentialSourceOAuth, nil
|
|
} else if creds.Type == "api_key" && creds.APIKey != "" {
|
|
return creds.APIKey, "stored API key", nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Fall back to environment variable
|
|
if envKey := os.Getenv("ANTHROPIC_API_KEY"); envKey != "" {
|
|
return envKey, "ANTHROPIC_API_KEY environment variable", nil
|
|
}
|
|
|
|
// Check if OpenAI credentials exist to provide a helpful suggestion
|
|
if cm != nil {
|
|
hasOpenAI, _ := cm.HasOpenAICredentials()
|
|
if hasOpenAI {
|
|
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag\n\nNote: OpenAI credentials were detected. To use OpenAI, run with --model openai/gpt-5.4 or set it as default:\n kit auth login openai --set-default")
|
|
}
|
|
}
|
|
|
|
return "", "", fmt.Errorf("no Anthropic API key found. Use 'kit auth login anthropic', set ANTHROPIC_API_KEY environment variable, or use --provider-api-key flag")
|
|
}
|