diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 6de7f9cd35..b8a831c87d 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -1,7 +1,8 @@ name: Lint & Test on: - workflow_dispatch: + pull_request: + types: [opened, synchronize, reopened] permissions: contents: read @@ -9,8 +10,9 @@ permissions: jobs: lint-test: name: lint-test - if: ${{ github.head_ref != 'chore/branding-slug-cleanup-20260303-clean' }} runs-on: ubuntu-latest steps: - - name: Skip JS/TS lint-test for Go project - run: echo "This is a Go project — JS/TS lint-test is not applicable. Go linting runs via golangci-lint workflow." + - name: Checkout + uses: actions/checkout@v4 + + - uses: KooshaPari/phenotypeActions/actions/lint-test@main diff --git a/cmd/cliproxyctl/main.go b/cmd/cliproxyctl/main.go index 8029aba403..33c55f3cef 100644 --- a/cmd/cliproxyctl/main.go +++ b/cmd/cliproxyctl/main.go @@ -18,6 +18,7 @@ import ( cliproxycmd "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/cmd" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" ) const responseSchemaVersion = "cliproxyctl.response.v1" diff --git a/cmd/cliproxyctl/main_test.go b/cmd/cliproxyctl/main_test.go index b2bb18611e..3fa26220b9 100644 --- a/cmd/cliproxyctl/main_test.go +++ b/cmd/cliproxyctl/main_test.go @@ -13,7 +13,8 @@ import ( "time" cliproxycmd "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/cmd" - "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" ) // repoRoot returns the absolute path to the repository root. diff --git a/go.mod b/go.mod index 19163f13ed..235fc36c56 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kooshapari/cliproxyapi-plusplus/v6 go 1.26.0 require ( + github.com/KooshaPari/phenotype-go-auth v0.0.0 github.com/andybalholm/brotli v1.2.0 github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 @@ -120,3 +121,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace github.com/KooshaPari/phenotype-go-auth => ../../../template-commons/phenotype-go-auth diff --git a/pkg/llmproxy/auth/qwen/qwen_token.go b/pkg/llmproxy/auth/qwen/qwen_token.go index 88e39c44d3..2b1aeec771 100644 --- a/pkg/llmproxy/auth/qwen/qwen_token.go +++ b/pkg/llmproxy/auth/qwen/qwen_token.go @@ -9,44 +9,53 @@ import ( "path/filepath" "strings" + "github.com/KooshaPari/phenotype-go-kit/pkg/auth" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/misc" ) -// BaseTokenStorage provides common token storage functionality shared across providers. -type BaseTokenStorage struct { - FilePath string `json:"-"` - Type string `json:"type"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - IDToken string `json:"id_token,omitempty"` - LastRefresh string `json:"last_refresh,omitempty"` - Expire string `json:"expired,omitempty"` -} +// QwenTokenStorage extends BaseTokenStorage with Qwen-specific fields for managing +// access tokens, refresh tokens, and user account information. +// It embeds auth.BaseTokenStorage to inherit shared token management functionality. +type QwenTokenStorage struct { + *auth.BaseTokenStorage -// NewBaseTokenStorage creates a new BaseTokenStorage with the given file path. -func NewBaseTokenStorage(filePath string) *BaseTokenStorage { - return &BaseTokenStorage{FilePath: filePath} + // ResourceURL is the base URL for API requests. + ResourceURL string `json:"resource_url"` } -// Save writes the token storage to its file path as JSON. -func (b *BaseTokenStorage) Save() error { - if b.FilePath == "" { - return fmt.Errorf("base token storage: file path is empty") - } - cleanPath := filepath.Clean(b.FilePath) - dir := filepath.Dir(cleanPath) - if err := os.MkdirAll(dir, 0700); err != nil { - return fmt.Errorf("failed to create directory: %w", err) +// NewQwenTokenStorage creates a new QwenTokenStorage instance with the given file path. +// Parameters: +// - filePath: The full path where the token file should be saved/loaded +// +// Returns: +// - *QwenTokenStorage: A new QwenTokenStorage instance +func NewQwenTokenStorage(filePath string) *QwenTokenStorage { + return &QwenTokenStorage{ + BaseTokenStorage: auth.NewBaseTokenStorage(filePath), } - f, err := os.Create(cleanPath) - if err != nil { - return fmt.Errorf("failed to create token file: %w", err) +} + +// SaveTokenToFile serializes the Qwen 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 (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error { + misc.LogSavingCredentials(authFilePath) + if ts.BaseTokenStorage == nil { + return fmt.Errorf("qwen token: base token storage is nil") } - defer func() { _ = f.Close() }() - if err := json.NewEncoder(f).Encode(b); err != nil { - return fmt.Errorf("failed to write token to file: %w", err) + + if _, err := cleanTokenFilePath(authFilePath, "qwen token"); err != nil { + return err } - return nil + + ts.BaseTokenStorage.Type = "qwen" + return ts.BaseTokenStorage.Save() } // QwenTokenStorage extends BaseTokenStorage with Qwen-specific fields for managing diff --git a/pkg/llmproxy/client/client_test.go b/pkg/llmproxy/client/client_test.go index 753e2aaa0c..2c6da92194 100644 --- a/pkg/llmproxy/client/client_test.go +++ b/pkg/llmproxy/client/client_test.go @@ -250,12 +250,12 @@ func TestResponses_OK(t *testing.T) { func TestWithAPIKey_SetsAuthorizationHeader(t *testing.T) { var gotAuth string - _, _ = newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, c := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") writeJSON(w, 200, map[string]any{"models": []any{}}) })) // Rebuild with API key - _, c := newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, c = newTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") writeJSON(w, 200, map[string]any{"models": []any{}}) })) diff --git a/pkg/llmproxy/client/types.go b/pkg/llmproxy/client/types.go index cfb3aef1a1..216dd69d71 100644 --- a/pkg/llmproxy/client/types.go +++ b/pkg/llmproxy/client/types.go @@ -113,9 +113,9 @@ func (e *APIError) Error() string { type Option func(*clientConfig) type clientConfig struct { - baseURL string - apiKey string - secretKey string + baseURL string + apiKey string + secretKey string httpTimeout time.Duration } diff --git a/pkg/llmproxy/executor/kiro_auth.go b/pkg/llmproxy/executor/kiro_auth.go index af80fe261b..2adf85d76f 100644 --- a/pkg/llmproxy/executor/kiro_auth.go +++ b/pkg/llmproxy/executor/kiro_auth.go @@ -15,6 +15,7 @@ import ( "github.com/google/uuid" kiroauth "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/kiro" + "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" cliproxyauth "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/auth" log "github.com/sirupsen/logrus" diff --git a/pkg/llmproxy/executor/kiro_executor.go b/pkg/llmproxy/executor/kiro_executor.go index c40b8b0cf1..cde163ec42 100644 --- a/pkg/llmproxy/executor/kiro_executor.go +++ b/pkg/llmproxy/executor/kiro_executor.go @@ -14,12 +14,11 @@ import ( "time" "github.com/google/uuid" - kiroclaude "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/translator/kiro/claude" - kirocommon "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/translator/kiro/common" - kiroopenai "github.com/kooshapari/cliproxyapi-plusplus/v6/internal/translator/kiro/openai" kiroauth "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/auth/kiro" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config" kiroclaude "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/translator/kiro/claude" + kirocommon "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/translator/kiro/common" + kiroopenai "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/translator/kiro/openai" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" cliproxyauth "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/auth" cliproxyexecutor "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/executor" @@ -762,6 +761,8 @@ func (e *KiroExecutor) executeWithRetry(ctx context.Context, auth *cliproxyauth. return resp, fmt.Errorf("kiro: all endpoints exhausted") } +// kiroCredentials extracts access token and profile ARN from auth. + // NOTE: Claude SSE event builders moved to pkg/llmproxy/translator/kiro/claude/kiro_claude_stream.go // The executor now uses kiroclaude.BuildClaude*Event() functions instead @@ -808,3 +809,379 @@ func (e *KiroExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, Payload: []byte(fmt.Sprintf(`{"count":%d}`, totalTokens)), }, nil } + +// Refresh refreshes the Kiro OAuth token. +// Supports both AWS Builder ID (SSO OIDC) and Google OAuth (social login). +// Uses mutex to prevent race conditions when multiple concurrent requests try to refresh. +func (e *KiroExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + // Serialize token refresh operations to prevent race conditions + e.refreshMu.Lock() + defer e.refreshMu.Unlock() + + var authID string + if auth != nil { + authID = auth.ID + } else { + authID = "" + } + log.Debugf("kiro executor: refresh called for auth %s", authID) + if auth == nil { + return nil, fmt.Errorf("kiro executor: auth is nil") + } + + // Double-check: After acquiring lock, verify token still needs refresh + // Another goroutine may have already refreshed while we were waiting + // NOTE: This check has a design limitation - it reads from the auth object passed in, + // not from persistent storage. If another goroutine returns a new Auth object (via Clone), + // this check won't see those updates. The mutex still prevents truly concurrent refreshes, + // but queued goroutines may still attempt redundant refreshes. This is acceptable as + // the refresh operation is idempotent and the extra API calls are infrequent. + if auth.Metadata != nil { + if lastRefresh, ok := auth.Metadata["last_refresh"].(string); ok { + if refreshTime, err := time.Parse(time.RFC3339, lastRefresh); err == nil { + // If token was refreshed within the last 30 seconds, skip refresh + if time.Since(refreshTime) < 30*time.Second { + log.Debugf("kiro executor: token was recently refreshed by another goroutine, skipping") + return auth, nil + } + } + } + // Also check if expires_at is now in the future with sufficient buffer + if expiresAt, ok := auth.Metadata["expires_at"].(string); ok { + if expTime, err := time.Parse(time.RFC3339, expiresAt); err == nil { + // If token expires more than 20 minutes from now, it's still valid + if time.Until(expTime) > 20*time.Minute { + log.Debugf("kiro executor: token is still valid (expires in %v), skipping refresh", time.Until(expTime)) + // CRITICAL FIX: Set NextRefreshAfter to prevent frequent refresh checks + // Without this, shouldRefresh() will return true again in 30 seconds + updated := auth.Clone() + // Set next refresh to 20 minutes before expiry, or at least 30 seconds from now + nextRefresh := expTime.Add(-20 * time.Minute) + minNextRefresh := time.Now().Add(30 * time.Second) + if nextRefresh.Before(minNextRefresh) { + nextRefresh = minNextRefresh + } + updated.NextRefreshAfter = nextRefresh + log.Debugf("kiro executor: setting NextRefreshAfter to %v (in %v)", nextRefresh.Format(time.RFC3339), time.Until(nextRefresh)) + return updated, nil + } + } + } + } + + var refreshToken string + var clientID, clientSecret string + var authMethod string + var region, startURL string + + if auth.Metadata != nil { + refreshToken = getMetadataString(auth.Metadata, "refresh_token", "refreshToken") + clientID = getMetadataString(auth.Metadata, "client_id", "clientId") + clientSecret = getMetadataString(auth.Metadata, "client_secret", "clientSecret") + authMethod = strings.ToLower(getMetadataString(auth.Metadata, "auth_method", "authMethod")) + region = getMetadataString(auth.Metadata, "region") + startURL = getMetadataString(auth.Metadata, "start_url", "startUrl") + } + + if refreshToken == "" { + return nil, fmt.Errorf("kiro executor: refresh token not found") + } + + var tokenData *kiroauth.KiroTokenData + var err error + + ssoClient := kiroauth.NewSSOOIDCClient(e.cfg) + + // Use SSO OIDC refresh for AWS Builder ID or IDC, otherwise use Kiro's OAuth refresh endpoint + switch { + case clientID != "" && clientSecret != "" && authMethod == "idc" && region != "": + // IDC refresh with region-specific endpoint + log.Debugf("kiro executor: using SSO OIDC refresh for IDC (region=%s)", region) + tokenData, err = ssoClient.RefreshTokenWithRegion(ctx, clientID, clientSecret, refreshToken, region, startURL) + case clientID != "" && clientSecret != "" && authMethod == "builder-id": + // Builder ID refresh with default endpoint + log.Debugf("kiro executor: using SSO OIDC refresh for AWS Builder ID") + tokenData, err = ssoClient.RefreshToken(ctx, clientID, clientSecret, refreshToken) + default: + // Fallback to Kiro's OAuth refresh endpoint (for social auth: Google/GitHub) + log.Debugf("kiro executor: using Kiro OAuth refresh endpoint") + oauth := kiroauth.NewKiroOAuth(e.cfg) + tokenData, err = oauth.RefreshToken(ctx, refreshToken) + } + + if err != nil { + return nil, fmt.Errorf("kiro executor: token refresh failed: %w", err) + } + + updated := auth.Clone() + now := time.Now() + updated.UpdatedAt = now + updated.LastRefreshedAt = now + + if updated.Metadata == nil { + updated.Metadata = make(map[string]any) + } + updated.Metadata["access_token"] = tokenData.AccessToken + updated.Metadata["refresh_token"] = tokenData.RefreshToken + updated.Metadata["expires_at"] = tokenData.ExpiresAt + updated.Metadata["last_refresh"] = now.Format(time.RFC3339) + if tokenData.ProfileArn != "" { + updated.Metadata["profile_arn"] = tokenData.ProfileArn + } + if tokenData.AuthMethod != "" { + updated.Metadata["auth_method"] = tokenData.AuthMethod + } + if tokenData.Provider != "" { + updated.Metadata["provider"] = tokenData.Provider + } + // Preserve client credentials for future refreshes (AWS Builder ID) + if tokenData.ClientID != "" { + updated.Metadata["client_id"] = tokenData.ClientID + } + if tokenData.ClientSecret != "" { + updated.Metadata["client_secret"] = tokenData.ClientSecret + } + // Preserve region and start_url for IDC token refresh + if tokenData.Region != "" { + updated.Metadata["region"] = tokenData.Region + } + if tokenData.StartURL != "" { + updated.Metadata["start_url"] = tokenData.StartURL + } + + if updated.Attributes == nil { + updated.Attributes = make(map[string]string) + } + updated.Attributes["access_token"] = tokenData.AccessToken + if tokenData.ProfileArn != "" { + updated.Attributes["profile_arn"] = tokenData.ProfileArn + } + + // NextRefreshAfter is aligned with RefreshLead (20min) + if expiresAt, parseErr := time.Parse(time.RFC3339, tokenData.ExpiresAt); parseErr == nil { + updated.NextRefreshAfter = expiresAt.Add(-20 * time.Minute) + } + + log.Infof("kiro executor: token refreshed successfully, expires at %s", tokenData.ExpiresAt) + return updated, nil +} + +// persistRefreshedAuth persists a refreshed auth record to disk. +// This ensures token refreshes from inline retry are saved to the auth file. +func (e *KiroExecutor) persistRefreshedAuth(auth *cliproxyauth.Auth) error { + if auth == nil || auth.Metadata == nil { + return fmt.Errorf("kiro executor: cannot persist nil auth or metadata") + } + + // Determine the file path from auth attributes or filename + var authPath string + if auth.Attributes != nil { + if p := strings.TrimSpace(auth.Attributes["path"]); p != "" { + authPath = p + } + } + if authPath == "" { + fileName := strings.TrimSpace(auth.FileName) + if fileName == "" { + return fmt.Errorf("kiro executor: auth has no file path or filename") + } + if filepath.IsAbs(fileName) { + authPath = fileName + } else if e.cfg != nil && e.cfg.AuthDir != "" { + authPath = filepath.Join(e.cfg.AuthDir, fileName) + } else { + return fmt.Errorf("kiro executor: cannot determine auth file path") + } + } + + // Marshal metadata to JSON + raw, err := json.Marshal(auth.Metadata) + if err != nil { + return fmt.Errorf("kiro executor: marshal metadata failed: %w", err) + } + + // Write to temp file first, then rename (atomic write) + tmp := authPath + ".tmp" + if err := os.WriteFile(tmp, raw, 0o600); err != nil { + return fmt.Errorf("kiro executor: write temp auth file failed: %w", err) + } + if err := os.Rename(tmp, authPath); err != nil { + return fmt.Errorf("kiro executor: rename auth file failed: %w", err) + } + + log.Debugf("kiro executor: persisted refreshed auth to %s", authPath) + return nil +} + +// reloadAuthFromFile 从文件重新加载 auth 数据(方案 B: Fallback 机制) +// 当内存中的 token 已过期时,尝试从文件读取最新的 token +// 这解决了后台刷新器已更新文件但内存中 Auth 对象尚未同步的时间差问题 +func (e *KiroExecutor) reloadAuthFromFile(auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { + if auth == nil { + return nil, fmt.Errorf("kiro executor: cannot reload nil auth") + } + + // 确定文件路径 + var authPath string + if auth.Attributes != nil { + if p := strings.TrimSpace(auth.Attributes["path"]); p != "" { + authPath = p + } + } + if authPath == "" { + fileName := strings.TrimSpace(auth.FileName) + if fileName == "" { + return nil, fmt.Errorf("kiro executor: auth has no file path or filename for reload") + } + if filepath.IsAbs(fileName) { + authPath = fileName + } else if e.cfg != nil && e.cfg.AuthDir != "" { + authPath = filepath.Join(e.cfg.AuthDir, fileName) + } else { + return nil, fmt.Errorf("kiro executor: cannot determine auth file path for reload") + } + } + + // 读取文件 + raw, err := os.ReadFile(authPath) + if err != nil { + return nil, fmt.Errorf("kiro executor: failed to read auth file %s: %w", authPath, err) + } + + // 解析 JSON + var metadata map[string]any + if err := json.Unmarshal(raw, &metadata); err != nil { + return nil, fmt.Errorf("kiro executor: failed to parse auth file %s: %w", authPath, err) + } + + // 检查文件中的 token 是否比内存中的更新 + fileExpiresAt, _ := metadata["expires_at"].(string) + fileAccessToken, _ := metadata["access_token"].(string) + memExpiresAt, _ := auth.Metadata["expires_at"].(string) + memAccessToken, _ := auth.Metadata["access_token"].(string) + + // 文件中必须有有效的 access_token + if fileAccessToken == "" { + return nil, fmt.Errorf("kiro executor: auth file has no access_token field") + } + + // 如果有 expires_at,检查是否过期 + if fileExpiresAt != "" { + fileExpTime, parseErr := time.Parse(time.RFC3339, fileExpiresAt) + if parseErr == nil { + // 如果文件中的 token 也已过期,不使用它 + if time.Now().After(fileExpTime) { + log.Debugf("kiro executor: file token also expired at %s, not using", fileExpiresAt) + return nil, fmt.Errorf("kiro executor: file token also expired") + } + } + } + + // 判断文件中的 token 是否比内存中的更新 + // 条件1: access_token 不同(说明已刷新) + // 条件2: expires_at 更新(说明已刷新) + isNewer := false + + // 优先检查 access_token 是否变化 + if fileAccessToken != memAccessToken { + isNewer = true + log.Debugf("kiro executor: file access_token differs from memory, using file token") + } + + // 如果 access_token 相同,检查 expires_at + if !isNewer && fileExpiresAt != "" && memExpiresAt != "" { + fileExpTime, fileParseErr := time.Parse(time.RFC3339, fileExpiresAt) + memExpTime, memParseErr := time.Parse(time.RFC3339, memExpiresAt) + if fileParseErr == nil && memParseErr == nil && fileExpTime.After(memExpTime) { + isNewer = true + log.Debugf("kiro executor: file expires_at (%s) is newer than memory (%s)", fileExpiresAt, memExpiresAt) + } + } + + // 如果文件中没有 expires_at 但 access_token 相同,无法判断是否更新 + if !isNewer && fileExpiresAt == "" && fileAccessToken == memAccessToken { + return nil, fmt.Errorf("kiro executor: cannot determine if file token is newer (no expires_at, same access_token)") + } + + if !isNewer { + log.Debugf("kiro executor: file token not newer than memory token") + return nil, fmt.Errorf("kiro executor: file token not newer") + } + + // 创建更新后的 auth 对象 + updated := auth.Clone() + updated.Metadata = metadata + updated.UpdatedAt = time.Now() + + // 同步更新 Attributes + if updated.Attributes == nil { + updated.Attributes = make(map[string]string) + } + if accessToken, ok := metadata["access_token"].(string); ok { + updated.Attributes["access_token"] = accessToken + } + if profileArn, ok := metadata["profile_arn"].(string); ok { + updated.Attributes["profile_arn"] = profileArn + } + + log.Infof("kiro executor: reloaded auth from file %s, new expires_at: %s", authPath, fileExpiresAt) + return updated, nil +} + +// isTokenExpired checks if a JWT access token has expired. +// Returns true if the token is expired or cannot be parsed. +func (e *KiroExecutor) isTokenExpired(accessToken string) bool { + if accessToken == "" { + return true + } + + // JWT tokens have 3 parts separated by dots + parts := strings.Split(accessToken, ".") + if len(parts) != 3 { + // Not a JWT token, assume not expired + return false + } + + // Decode the payload (second part) + // JWT uses base64url encoding without padding (RawURLEncoding) + payload := parts[1] + decoded, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + // Try with padding added as fallback + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + decoded, err = base64.URLEncoding.DecodeString(payload) + if err != nil { + log.Debugf("kiro: failed to decode JWT payload: %v", err) + return false + } + } + + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(decoded, &claims); err != nil { + log.Debugf("kiro: failed to parse JWT claims: %v", err) + return false + } + + if claims.Exp == 0 { + // No expiration claim, assume not expired + return false + } + + expTime := time.Unix(claims.Exp, 0) + now := time.Now() + + // Consider token expired if it expires within 1 minute (buffer for clock skew) + isExpired := now.After(expTime) || expTime.Sub(now) < time.Minute + if isExpired { + log.Debugf("kiro: token expired at %s (now: %s)", expTime.Format(time.RFC3339), now.Format(time.RFC3339)) + } + + return isExpired +} diff --git a/pkg/llmproxy/executor/kiro_streaming.go b/pkg/llmproxy/executor/kiro_streaming.go index 2126c7623c..2e3ea70162 100644 --- a/pkg/llmproxy/executor/kiro_streaming.go +++ b/pkg/llmproxy/executor/kiro_streaming.go @@ -19,8 +19,8 @@ import ( kiroclaude "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/translator/kiro/claude" kirocommon "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/translator/kiro/common" "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" - cliproxyauth "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/auth" - cliproxyexecutor "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/executor" + clipproxyauth "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/auth" + clipproxyexecutor "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/executor" "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/usage" sdktranslator "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/translator" log "github.com/sirupsen/logrus" @@ -433,357 +433,6 @@ func (e *KiroExecutor) executeStreamWithRetry(ctx context.Context, auth *cliprox return nil, fmt.Errorf("kiro: stream all endpoints exhausted") } -// kiroCredentials extracts access token and profile ARN from auth. -func kiroCredentials(auth *cliproxyauth.Auth) (accessToken, profileArn string) { - if auth == nil { - return "", "" - } - - // Try Metadata first (wrapper format) - if auth.Metadata != nil { - if token, ok := auth.Metadata["access_token"].(string); ok { - accessToken = token - } - if arn, ok := auth.Metadata["profile_arn"].(string); ok { - profileArn = arn - } - } - - // Try Attributes - if accessToken == "" && auth.Attributes != nil { - accessToken = auth.Attributes["access_token"] - profileArn = auth.Attributes["profile_arn"] - } - - // Try direct fields from flat JSON format (new AWS Builder ID format) - if accessToken == "" && auth.Metadata != nil { - if token, ok := auth.Metadata["accessToken"].(string); ok { - accessToken = token - } - if arn, ok := auth.Metadata["profileArn"].(string); ok { - profileArn = arn - } - } - - return accessToken, profileArn -} - -// findRealThinkingEndTag finds the real end tag, skipping false positives. -// Returns -1 if no real end tag is found. -// -// Real tags from Kiro API have specific characteristics: -// - Usually preceded by newline (.\n) -// - Usually followed by newline (\n\n) -// - Not inside code blocks or inline code -// -// False positives (discussion text) have characteristics: -// - In the middle of a sentence -// - Preceded by discussion words like "标签", "tag", "returns" -// - Inside code blocks or inline code -// -// Parameters: -// - content: the content to search in -// - alreadyInCodeBlock: whether we're already inside a code block from previous chunks -// - alreadyInInlineCode: whether we're already inside inline code from previous chunks -func findRealThinkingEndTag(content string, alreadyInCodeBlock, alreadyInInlineCode bool) int { - searchStart := 0 - for { - endIdx := strings.Index(content[searchStart:], kirocommon.ThinkingEndTag) - if endIdx < 0 { - return -1 - } - endIdx += searchStart // Adjust to absolute position - - textBeforeEnd := content[:endIdx] - textAfterEnd := content[endIdx+len(kirocommon.ThinkingEndTag):] - - // Check 1: Is it inside inline code? - // Count backticks in current content and add state from previous chunks - backtickCount := strings.Count(textBeforeEnd, "`") - effectiveInInlineCode := alreadyInInlineCode - if backtickCount%2 == 1 { - effectiveInInlineCode = !effectiveInInlineCode - } - if effectiveInInlineCode { - log.Debugf("kiro: found inside inline code at pos %d, skipping", endIdx) - searchStart = endIdx + len(kirocommon.ThinkingEndTag) - continue - } - - // Check 2: Is it inside a code block? - // Count fences in current content and add state from previous chunks - fenceCount := strings.Count(textBeforeEnd, "```") - altFenceCount := strings.Count(textBeforeEnd, "~~~") - effectiveInCodeBlock := alreadyInCodeBlock - if fenceCount%2 == 1 || altFenceCount%2 == 1 { - effectiveInCodeBlock = !effectiveInCodeBlock - } - if effectiveInCodeBlock { - log.Debugf("kiro: found inside code block at pos %d, skipping", endIdx) - searchStart = endIdx + len(kirocommon.ThinkingEndTag) - continue - } - - // Check 3: Real tags are usually preceded by newline or at start - // and followed by newline or at end. Check the format. - charBeforeTag := byte(0) - if endIdx > 0 { - charBeforeTag = content[endIdx-1] - } - charAfterTag := byte(0) - if len(textAfterEnd) > 0 { - charAfterTag = textAfterEnd[0] - } - - // Real end tag format: preceded by newline OR end of sentence (. ! ?) - // and followed by newline OR end of content - isPrecededByNewlineOrSentenceEnd := charBeforeTag == '\n' || charBeforeTag == '.' || - charBeforeTag == '!' || charBeforeTag == '?' || charBeforeTag == 0 - isFollowedByNewlineOrEnd := charAfterTag == '\n' || charAfterTag == 0 - - // If the tag has proper formatting (newline before/after), it's likely real - if isPrecededByNewlineOrSentenceEnd && isFollowedByNewlineOrEnd { - log.Debugf("kiro: found properly formatted at pos %d", endIdx) - return endIdx - } - - // Check 4: Is the tag preceded by discussion keywords on the same line? - lastNewlineIdx := strings.LastIndex(textBeforeEnd, "\n") - lineBeforeTag := textBeforeEnd - if lastNewlineIdx >= 0 { - lineBeforeTag = textBeforeEnd[lastNewlineIdx+1:] - } - lineBeforeTagLower := strings.ToLower(lineBeforeTag) - - // Discussion patterns - if found, this is likely discussion text - discussionPatterns := []string{ - "标签", "返回", "输出", "包含", "使用", "解析", "转换", "生成", // Chinese - "tag", "return", "output", "contain", "use", "parse", "emit", "convert", "generate", // English - "", // discussing both tags together - "``", // explicitly in inline code - } - isDiscussion := false - for _, pattern := range discussionPatterns { - if strings.Contains(lineBeforeTagLower, pattern) { - isDiscussion = true - break - } - } - if isDiscussion { - log.Debugf("kiro: found after discussion text at pos %d, skipping", endIdx) - searchStart = endIdx + len(kirocommon.ThinkingEndTag) - continue - } - - // Check 5: Is there text immediately after on the same line? - // Real end tags don't have text immediately after on the same line - if len(textAfterEnd) > 0 && charAfterTag != '\n' && charAfterTag != 0 { - // Find the next newline - nextNewline := strings.Index(textAfterEnd, "\n") - var textOnSameLine string - if nextNewline >= 0 { - textOnSameLine = textAfterEnd[:nextNewline] - } else { - textOnSameLine = textAfterEnd - } - // If there's non-whitespace text on the same line after the tag, it's discussion - if strings.TrimSpace(textOnSameLine) != "" { - log.Debugf("kiro: found with text after on same line at pos %d, skipping", endIdx) - searchStart = endIdx + len(kirocommon.ThinkingEndTag) - continue - } - } - - // Check 6: Is there another tag after this ? - if strings.Contains(textAfterEnd, kirocommon.ThinkingStartTag) { - nextStartIdx := strings.Index(textAfterEnd, kirocommon.ThinkingStartTag) - textBeforeNextStart := textAfterEnd[:nextStartIdx] - nextBacktickCount := strings.Count(textBeforeNextStart, "`") - nextFenceCount := strings.Count(textBeforeNextStart, "```") - nextAltFenceCount := strings.Count(textBeforeNextStart, "~~~") - - // If the next is NOT in code, then this is discussion text - if nextBacktickCount%2 == 0 && nextFenceCount%2 == 0 && nextAltFenceCount%2 == 0 { - log.Debugf("kiro: found followed by at pos %d, likely discussion text, skipping", endIdx) - searchStart = endIdx + len(kirocommon.ThinkingEndTag) - continue - } - } - - // This looks like a real end tag - return endIdx - } -} - -// determineAgenticMode determines if the model is an agentic or chat-only variant. -// Returns (isAgentic, isChatOnly) based on model name suffixes. -func determineAgenticMode(model string) (isAgentic, isChatOnly bool) { - isAgentic = strings.HasSuffix(model, "-agentic") - isChatOnly = strings.HasSuffix(model, "-chat") - return isAgentic, isChatOnly -} - -// getEffectiveProfileArn determines if profileArn should be included based on auth method. -// profileArn is only needed for social auth (Google OAuth), not for AWS SSO OIDC (Builder ID/IDC). -// -// Detection logic (matching kiro-openai-gateway): -// 1. Check auth_method field: "builder-id" or "idc" -// 2. Check auth_type field: "aws_sso_oidc" (from kiro-cli tokens) -// 3. Check for client_id + client_secret presence (AWS SSO OIDC signature) -func getEffectiveProfileArn(auth *cliproxyauth.Auth, profileArn string) string { - if auth != nil && auth.Metadata != nil { - // Check 1: auth_method field (from CLIProxyAPI tokens) - if authMethod, ok := auth.Metadata["auth_method"].(string); ok && (authMethod == "builder-id" || authMethod == "idc") { - return "" // AWS SSO OIDC - don't include profileArn - } - // Check 2: auth_type field (from kiro-cli tokens) - if authType, ok := auth.Metadata["auth_type"].(string); ok && authType == "aws_sso_oidc" { - return "" // AWS SSO OIDC - don't include profileArn - } - // Check 3: client_id + client_secret presence (AWS SSO OIDC signature) - _, hasClientID := auth.Metadata["client_id"].(string) - _, hasClientSecret := auth.Metadata["client_secret"].(string) - if hasClientID && hasClientSecret { - return "" // AWS SSO OIDC - don't include profileArn - } - } - return profileArn -} - -// getEffectiveProfileArnWithWarning determines if profileArn should be included based on auth method, -// and logs a warning if profileArn is missing for non-builder-id auth. -// This consolidates the auth_method check that was previously done separately. -// -// AWS SSO OIDC (Builder ID/IDC) users don't need profileArn - sending it causes 403 errors. -// Only Kiro Desktop (social auth like Google/GitHub) users need profileArn. -// -// Detection logic (matching kiro-openai-gateway): -// 1. Check auth_method field: "builder-id" or "idc" -// 2. Check auth_type field: "aws_sso_oidc" (from kiro-cli tokens) -// 3. Check for client_id + client_secret presence (AWS SSO OIDC signature) -func getEffectiveProfileArnWithWarning(auth *cliproxyauth.Auth, profileArn string) string { - if auth != nil && auth.Metadata != nil { - // Check 1: auth_method field (from CLIProxyAPI tokens) - if authMethod, ok := auth.Metadata["auth_method"].(string); ok && (authMethod == "builder-id" || authMethod == "idc") { - return "" // AWS SSO OIDC - don't include profileArn - } - // Check 2: auth_type field (from kiro-cli tokens) - if authType, ok := auth.Metadata["auth_type"].(string); ok && authType == "aws_sso_oidc" { - return "" // AWS SSO OIDC - don't include profileArn - } - // Check 3: client_id + client_secret presence (AWS SSO OIDC signature, like kiro-openai-gateway) - _, hasClientID := auth.Metadata["client_id"].(string) - _, hasClientSecret := auth.Metadata["client_secret"].(string) - if hasClientID && hasClientSecret { - return "" // AWS SSO OIDC - don't include profileArn - } - } - // For social auth (Kiro Desktop), profileArn is required - if profileArn == "" { - log.Warnf("kiro: profile ARN not found in auth, API calls may fail") - } - return profileArn -} - -// mapModelToKiro maps external model names to Kiro model IDs. -// Supports both Kiro and Amazon Q prefixes since they use the same API. -// Agentic variants (-agentic suffix) map to the same backend model IDs. -func (e *KiroExecutor) mapModelToKiro(model string) string { - modelMap := map[string]string{ - // Amazon Q format (amazonq- prefix) - same API as Kiro - "amazonq-auto": "auto", - "amazonq-claude-opus-4-6": "claude-opus-4.6", - "amazonq-claude-sonnet-4-6": "claude-sonnet-4.6", - "amazonq-claude-opus-4-5": "claude-opus-4.5", - "amazonq-claude-sonnet-4-5": "claude-sonnet-4.5", - "amazonq-claude-sonnet-4-5-20250929": "claude-sonnet-4.5", - "amazonq-claude-sonnet-4": "claude-sonnet-4", - "amazonq-claude-sonnet-4-20250514": "claude-sonnet-4", - "amazonq-claude-haiku-4-5": "claude-haiku-4.5", - // Kiro format (kiro- prefix) - valid model names that should be preserved - "kiro-claude-opus-4-6": "claude-opus-4.6", - "kiro-claude-sonnet-4-6": "claude-sonnet-4.6", - "kiro-claude-opus-4-5": "claude-opus-4.5", - "kiro-claude-sonnet-4-5": "claude-sonnet-4.5", - "kiro-claude-sonnet-4-5-20250929": "claude-sonnet-4.5", - "kiro-claude-sonnet-4": "claude-sonnet-4", - "kiro-claude-sonnet-4-20250514": "claude-sonnet-4", - "kiro-claude-haiku-4-5": "claude-haiku-4.5", - "kiro-auto": "auto", - // Native format (no prefix) - used by Kiro IDE directly - "claude-opus-4-6": "claude-opus-4.6", - "claude-opus-4.6": "claude-opus-4.6", - "claude-sonnet-4-6": "claude-sonnet-4.6", - "claude-sonnet-4.6": "claude-sonnet-4.6", - "claude-opus-4-5": "claude-opus-4.5", - "claude-opus-4.5": "claude-opus-4.5", - "claude-haiku-4-5": "claude-haiku-4.5", - "claude-haiku-4.5": "claude-haiku-4.5", - "claude-sonnet-4-5": "claude-sonnet-4.5", - "claude-sonnet-4-5-20250929": "claude-sonnet-4.5", - "claude-sonnet-4.5": "claude-sonnet-4.5", - "claude-sonnet-4": "claude-sonnet-4", - "claude-sonnet-4-20250514": "claude-sonnet-4", - "auto": "auto", - // Agentic variants (same backend model IDs, but with special system prompt) - "claude-opus-4.6-agentic": "claude-opus-4.6", - "claude-sonnet-4.6-agentic": "claude-sonnet-4.6", - "claude-opus-4.5-agentic": "claude-opus-4.5", - "claude-sonnet-4.5-agentic": "claude-sonnet-4.5", - "claude-sonnet-4-agentic": "claude-sonnet-4", - "claude-haiku-4.5-agentic": "claude-haiku-4.5", - "kiro-claude-opus-4-6-agentic": "claude-opus-4.6", - "kiro-claude-sonnet-4-6-agentic": "claude-sonnet-4.6", - "kiro-claude-opus-4-5-agentic": "claude-opus-4.5", - "kiro-claude-sonnet-4-5-agentic": "claude-sonnet-4.5", - "kiro-claude-sonnet-4-agentic": "claude-sonnet-4", - "kiro-claude-haiku-4-5-agentic": "claude-haiku-4.5", - } - if kiroID, ok := modelMap[model]; ok { - return kiroID - } - - // Smart fallback: try to infer model type from name patterns - modelLower := strings.ToLower(model) - - // Check for Haiku variants - if strings.Contains(modelLower, "haiku") { - log.Debugf("kiro: unknown Haiku model '%s', mapping to claude-haiku-4.5", model) - return "claude-haiku-4.5" - } - - // Check for Sonnet variants - if strings.Contains(modelLower, "sonnet") { - // Check for specific version patterns - if strings.Contains(modelLower, "3-7") || strings.Contains(modelLower, "3.7") { - log.Debugf("kiro: unknown Sonnet 3.7 model '%s', mapping to claude-3-7-sonnet-20250219", model) - return "claude-3-7-sonnet-20250219" - } - if strings.Contains(modelLower, "4-6") || strings.Contains(modelLower, "4.6") { - log.Debugf("kiro: unknown Sonnet 4.6 model '%s', mapping to claude-sonnet-4.6", model) - return "claude-sonnet-4.6" - } - if strings.Contains(modelLower, "4-5") || strings.Contains(modelLower, "4.5") { - log.Debugf("kiro: unknown Sonnet 4.5 model '%s', mapping to claude-sonnet-4.5", model) - return "claude-sonnet-4.5" - } - } - - // Check for Opus variants - if strings.Contains(modelLower, "opus") { - if strings.Contains(modelLower, "4-6") || strings.Contains(modelLower, "4.6") { - log.Debugf("kiro: unknown Opus 4.6 model '%s', mapping to claude-opus-4.6", model) - return "claude-opus-4.6" - } - log.Debugf("kiro: unknown Opus model '%s', mapping to claude-opus-4.5", model) - return "claude-opus-4.5" - } - - // Final fallback to Sonnet 4.5 (most commonly used model) - log.Warnf("kiro: unknown model '%s', falling back to claude-sonnet-4.5", model) - return "claude-sonnet-4.5" -} - // EventStreamError represents an Event Stream processing error type EventStreamError struct { Type string // "fatal", "malformed" diff --git a/pkg/llmproxy/executor/kiro_transform.go b/pkg/llmproxy/executor/kiro_transform.go index 940901a76c..78c235edfc 100644 --- a/pkg/llmproxy/executor/kiro_transform.go +++ b/pkg/llmproxy/executor/kiro_transform.go @@ -10,7 +10,7 @@ import ( kiroclaude "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/translator/kiro/claude" kiroopenai "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/translator/kiro/openai" - cliproxyauth "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/auth" + clipproxyauth "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/auth" sdktranslator "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/translator" log "github.com/sirupsen/logrus" ) @@ -194,6 +194,15 @@ func getKiroEndpointConfigs(auth *cliproxyauth.Auth) []kiroEndpointConfig { return append(sorted, remaining...) } +// isIDCAuth checks if the auth uses IDC (Identity Center) authentication method. +func isIDCAuth(auth *cliproxyauth.Auth) bool { + if auth == nil || auth.Metadata == nil { + return false + } + authMethod, _ := auth.Metadata["auth_method"].(string) + return strings.ToLower(authMethod) == "idc" +} + // buildKiroPayloadForFormat builds the Kiro API payload based on the source format. // This is critical because OpenAI and Claude formats have different tool structures: // - OpenAI: tools[].function.name, tools[].function.description @@ -232,6 +241,40 @@ func sanitizeKiroPayload(body []byte) []byte { return sanitized } +func kiroCredentials(auth *cliproxyauth.Auth) (accessToken, profileArn string) { + if auth == nil { + return "", "" + } + + // Try Metadata first (wrapper format) + if auth.Metadata != nil { + if token, ok := auth.Metadata["access_token"].(string); ok { + accessToken = token + } + if arn, ok := auth.Metadata["profile_arn"].(string); ok { + profileArn = arn + } + } + + // Try Attributes + if accessToken == "" && auth.Attributes != nil { + accessToken = auth.Attributes["access_token"] + profileArn = auth.Attributes["profile_arn"] + } + + // Try direct fields from flat JSON format (new AWS Builder ID format) + if accessToken == "" && auth.Metadata != nil { + if token, ok := auth.Metadata["accessToken"].(string); ok { + accessToken = token + } + if arn, ok := auth.Metadata["profileArn"].(string); ok { + profileArn = arn + } + } + + return accessToken, profileArn +} + // findRealThinkingEndTag finds the real end tag, skipping false positives. // Returns -1 if no real end tag is found. // diff --git a/pkg/llmproxy/usage/metrics.go b/pkg/llmproxy/usage/metrics.go index f41dc58ad6..f4b157872c 100644 --- a/pkg/llmproxy/usage/metrics.go +++ b/pkg/llmproxy/usage/metrics.go @@ -4,7 +4,7 @@ package usage import ( "strings" - "github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/util" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/util" ) func normalizeProvider(apiKey string) string {