diff --git a/cmd/server/main.go b/cmd/server/main.go index fe648f6c0..0f6e817e2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -78,6 +78,7 @@ func main() { var kiroLogin bool var kiroGoogleLogin bool var kiroAWSLogin bool + var kiroAWSAuthCode bool var kiroImport bool var githubCopilotLogin bool var projectID string @@ -101,6 +102,7 @@ func main() { flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth") flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)") flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)") + flag.BoolVar(&kiroAWSAuthCode, "kiro-aws-authcode", false, "Login to Kiro using AWS Builder ID (authorization code flow, better UX)") flag.BoolVar(&kiroImport, "kiro-import", false, "Import Kiro token from Kiro IDE (~/.aws/sso/cache/kiro-auth-token.json)") flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") @@ -513,6 +515,10 @@ func main() { // Users can explicitly override with --no-incognito setKiroIncognitoMode(cfg, useIncognito, noIncognito) cmd.DoKiroAWSLogin(cfg, options) + } else if kiroAWSAuthCode { + // For Kiro auth with authorization code flow (better UX) + setKiroIncognitoMode(cfg, useIncognito, noIncognito) + cmd.DoKiroAWSAuthCodeLogin(cfg, options) } else if kiroImport { cmd.DoKiroImport(cfg, options) } else { diff --git a/internal/auth/kiro/codewhisperer_client.go b/internal/auth/kiro/codewhisperer_client.go new file mode 100644 index 000000000..0a7392e82 --- /dev/null +++ b/internal/auth/kiro/codewhisperer_client.go @@ -0,0 +1,166 @@ +// Package kiro provides CodeWhisperer API client for fetching user info. +package kiro + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/util" + log "github.com/sirupsen/logrus" +) + +const ( + codeWhispererAPI = "https://codewhisperer.us-east-1.amazonaws.com" + kiroVersion = "0.6.18" +) + +// CodeWhispererClient handles CodeWhisperer API calls. +type CodeWhispererClient struct { + httpClient *http.Client + machineID string +} + +// UsageLimitsResponse represents the getUsageLimits API response. +type UsageLimitsResponse struct { + DaysUntilReset *int `json:"daysUntilReset,omitempty"` + NextDateReset *float64 `json:"nextDateReset,omitempty"` + UserInfo *UserInfo `json:"userInfo,omitempty"` + SubscriptionInfo *SubscriptionInfo `json:"subscriptionInfo,omitempty"` + UsageBreakdownList []UsageBreakdown `json:"usageBreakdownList,omitempty"` +} + +// UserInfo contains user information from the API. +type UserInfo struct { + Email string `json:"email,omitempty"` + UserID string `json:"userId,omitempty"` +} + +// SubscriptionInfo contains subscription details. +type SubscriptionInfo struct { + SubscriptionTitle string `json:"subscriptionTitle,omitempty"` + Type string `json:"type,omitempty"` +} + +// UsageBreakdown contains usage details. +type UsageBreakdown struct { + UsageLimit *int `json:"usageLimit,omitempty"` + CurrentUsage *int `json:"currentUsage,omitempty"` + UsageLimitWithPrecision *float64 `json:"usageLimitWithPrecision,omitempty"` + CurrentUsageWithPrecision *float64 `json:"currentUsageWithPrecision,omitempty"` + NextDateReset *float64 `json:"nextDateReset,omitempty"` + DisplayName string `json:"displayName,omitempty"` + ResourceType string `json:"resourceType,omitempty"` +} + +// NewCodeWhispererClient creates a new CodeWhisperer client. +func NewCodeWhispererClient(cfg *config.Config, machineID string) *CodeWhispererClient { + client := &http.Client{Timeout: 30 * time.Second} + if cfg != nil { + client = util.SetProxy(&cfg.SDKConfig, client) + } + if machineID == "" { + machineID = uuid.New().String() + } + return &CodeWhispererClient{ + httpClient: client, + machineID: machineID, + } +} + +// generateInvocationID generates a unique invocation ID. +func generateInvocationID() string { + return uuid.New().String() +} + +// GetUsageLimits fetches usage limits and user info from CodeWhisperer API. +// This is the recommended way to get user email after login. +func (c *CodeWhispererClient) GetUsageLimits(ctx context.Context, accessToken string) (*UsageLimitsResponse, error) { + url := fmt.Sprintf("%s/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST", codeWhispererAPI) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Set headers to match Kiro IDE + xAmzUserAgent := fmt.Sprintf("aws-sdk-js/1.0.0 KiroIDE-%s-%s", kiroVersion, c.machineID) + userAgent := fmt.Sprintf("aws-sdk-js/1.0.0 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererruntime#1.0.0 m/E KiroIDE-%s-%s", kiroVersion, c.machineID) + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("x-amz-user-agent", xAmzUserAgent) + req.Header.Set("User-Agent", userAgent) + req.Header.Set("amz-sdk-invocation-id", generateInvocationID()) + req.Header.Set("amz-sdk-request", "attempt=1; max=1") + req.Header.Set("Connection", "close") + + log.Debugf("codewhisperer: GET %s", url) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + log.Debugf("codewhisperer: status=%d, body=%s", resp.StatusCode, string(body)) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + var result UsageLimitsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +// FetchUserEmailFromAPI fetches user email using CodeWhisperer getUsageLimits API. +// This is more reliable than JWT parsing as it uses the official API. +func (c *CodeWhispererClient) FetchUserEmailFromAPI(ctx context.Context, accessToken string) string { + resp, err := c.GetUsageLimits(ctx, accessToken) + if err != nil { + log.Debugf("codewhisperer: failed to get usage limits: %v", err) + return "" + } + + if resp.UserInfo != nil && resp.UserInfo.Email != "" { + log.Debugf("codewhisperer: got email from API: %s", resp.UserInfo.Email) + return resp.UserInfo.Email + } + + log.Debugf("codewhisperer: no email in response") + return "" +} + +// FetchUserEmailWithFallback fetches user email with multiple fallback methods. +// Priority: 1. CodeWhisperer API 2. userinfo endpoint 3. JWT parsing +func FetchUserEmailWithFallback(ctx context.Context, cfg *config.Config, accessToken string) string { + // Method 1: Try CodeWhisperer API (most reliable) + cwClient := NewCodeWhispererClient(cfg, "") + email := cwClient.FetchUserEmailFromAPI(ctx, accessToken) + if email != "" { + return email + } + + // Method 2: Try SSO OIDC userinfo endpoint + ssoClient := NewSSOOIDCClient(cfg) + email = ssoClient.FetchUserEmail(ctx, accessToken) + if email != "" { + return email + } + + // Method 3: Fallback to JWT parsing + return ExtractEmailFromJWT(accessToken) +} diff --git a/internal/auth/kiro/oauth.go b/internal/auth/kiro/oauth.go index e828da141..a7d3eb9a5 100644 --- a/internal/auth/kiro/oauth.go +++ b/internal/auth/kiro/oauth.go @@ -163,6 +163,13 @@ func (o *KiroOAuth) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, err return ssoClient.LoginWithBuilderID(ctx) } +// LoginWithBuilderIDAuthCode performs OAuth login with AWS Builder ID using authorization code flow. +// This provides a better UX than device code flow as it uses automatic browser callback. +func (o *KiroOAuth) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) { + ssoClient := NewSSOOIDCClient(o.cfg) + return ssoClient.LoginWithBuilderIDAuthCode(ctx) +} + // exchangeCodeForToken exchanges the authorization code for tokens. func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) { payload := map[string]string{ diff --git a/internal/auth/kiro/sso_oidc.go b/internal/auth/kiro/sso_oidc.go index d3c27d16f..2c9150f1b 100644 --- a/internal/auth/kiro/sso_oidc.go +++ b/internal/auth/kiro/sso_oidc.go @@ -3,9 +3,14 @@ package kiro import ( "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" + "html" "io" + "net" "net/http" "strings" "time" @@ -25,6 +30,13 @@ const ( // Polling interval pollInterval = 5 * time.Second + + // Authorization code flow callback + authCodeCallbackPath = "/oauth/callback" + authCodeCallbackPort = 19877 + + // User-Agent to match official Kiro IDE + kiroUserAgent = "KiroIDE" ) // SSOOIDCClient handles AWS SSO OIDC authentication. @@ -73,13 +85,11 @@ type CreateTokenResponse struct { // RegisterClient registers a new OIDC client with AWS. func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResponse, error) { - // Generate unique client name for each registration to support multiple accounts - clientName := fmt.Sprintf("CLI-Proxy-API-%d", time.Now().UnixNano()) - payload := map[string]interface{}{ - "clientName": clientName, + "clientName": "Kiro IDE", "clientType": "public", - "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"}, + "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"}, + "grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"}, } body, err := json.Marshal(payload) @@ -92,6 +102,7 @@ func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResp return nil, err } req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", kiroUserAgent) resp, err := c.httpClient.Do(req) if err != nil { @@ -135,6 +146,7 @@ func (c *SSOOIDCClient) StartDeviceAuthorization(ctx context.Context, clientID, return nil, err } req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", kiroUserAgent) resp, err := c.httpClient.Do(req) if err != nil { @@ -179,6 +191,7 @@ func (c *SSOOIDCClient) CreateToken(ctx context.Context, clientID, clientSecret, return nil, err } req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", kiroUserAgent) resp, err := c.httpClient.Do(req) if err != nil { @@ -240,6 +253,7 @@ func (c *SSOOIDCClient) RefreshToken(ctx context.Context, clientID, clientSecret return nil, err } req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", kiroUserAgent) resp, err := c.httpClient.Do(req) if err != nil { @@ -370,8 +384,8 @@ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, fmt.Println("Fetching profile information...") profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken) - // Extract email from JWT access token - email := ExtractEmailFromJWT(tokenResp.AccessToken) + // Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing) + email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken) if email != "" { fmt.Printf(" Logged in as: %s\n", email) } @@ -399,6 +413,68 @@ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, return nil, fmt.Errorf("authorization timed out") } +// FetchUserEmail retrieves the user's email from AWS SSO OIDC userinfo endpoint. +// Falls back to JWT parsing if userinfo fails. +func (c *SSOOIDCClient) FetchUserEmail(ctx context.Context, accessToken string) string { + // Method 1: Try userinfo endpoint (standard OIDC) + email := c.tryUserInfoEndpoint(ctx, accessToken) + if email != "" { + return email + } + + // Method 2: Fallback to JWT parsing + return ExtractEmailFromJWT(accessToken) +} + +// tryUserInfoEndpoint attempts to get user info from AWS SSO OIDC userinfo endpoint. +func (c *SSOOIDCClient) tryUserInfoEndpoint(ctx context.Context, accessToken string) string { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, ssoOIDCEndpoint+"/userinfo", nil) + if err != nil { + return "" + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + log.Debugf("userinfo request failed: %v", err) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + log.Debugf("userinfo endpoint returned status %d: %s", resp.StatusCode, string(respBody)) + return "" + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + + log.Debugf("userinfo response: %s", string(respBody)) + + var userInfo struct { + Email string `json:"email"` + Sub string `json:"sub"` + PreferredUsername string `json:"preferred_username"` + Name string `json:"name"` + } + + if err := json.Unmarshal(respBody, &userInfo); err != nil { + return "" + } + + if userInfo.Email != "" { + return userInfo.Email + } + if userInfo.PreferredUsername != "" && strings.Contains(userInfo.PreferredUsername, "@") { + return userInfo.PreferredUsername + } + return "" +} + // fetchProfileArn retrieves the profile ARN from CodeWhisperer API. // This is needed for file naming since AWS SSO OIDC doesn't return profile info. func (c *SSOOIDCClient) fetchProfileArn(ctx context.Context, accessToken string) string { @@ -525,3 +601,323 @@ func (c *SSOOIDCClient) tryListCustomizations(ctx context.Context, accessToken s return "" } + +// RegisterClientForAuthCode registers a new OIDC client for authorization code flow. +func (c *SSOOIDCClient) RegisterClientForAuthCode(ctx context.Context, redirectURI string) (*RegisterClientResponse, error) { + payload := map[string]interface{}{ + "clientName": "Kiro IDE", + "clientType": "public", + "scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"}, + "grantTypes": []string{"authorization_code", "refresh_token"}, + "redirectUris": []string{redirectURI}, + "issuerUrl": builderIDStartURL, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/client/register", strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", kiroUserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + log.Debugf("register client for auth code failed (status %d): %s", resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode) + } + + var result RegisterClientResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + + return &result, nil +} + +// AuthCodeCallbackResult contains the result from authorization code callback. +type AuthCodeCallbackResult struct { + Code string + State string + Error string +} + +// startAuthCodeCallbackServer starts a local HTTP server to receive the authorization code callback. +func (c *SSOOIDCClient) startAuthCodeCallbackServer(ctx context.Context, expectedState string) (string, <-chan AuthCodeCallbackResult, error) { + // Try to find an available port + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", authCodeCallbackPort)) + if err != nil { + // Try with dynamic port + log.Warnf("sso oidc: default port %d is busy, falling back to dynamic port", authCodeCallbackPort) + listener, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", nil, fmt.Errorf("failed to start callback server: %w", err) + } + } + + port := listener.Addr().(*net.TCPAddr).Port + redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, authCodeCallbackPath) + resultChan := make(chan AuthCodeCallbackResult, 1) + + server := &http.Server{ + ReadHeaderTimeout: 10 * time.Second, + } + + mux := http.NewServeMux() + mux.HandleFunc(authCodeCallbackPath, func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + errParam := r.URL.Query().Get("error") + + // Send response to browser + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if errParam != "" { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, ` +Login Failed +

Login Failed

Error: %s

You can close this window.

`, html.EscapeString(errParam)) + resultChan <- AuthCodeCallbackResult{Error: errParam} + return + } + + if state != expectedState { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, ` +Login Failed +

Login Failed

Invalid state parameter

You can close this window.

`) + resultChan <- AuthCodeCallbackResult{Error: "state mismatch"} + return + } + + fmt.Fprint(w, ` +Login Successful +

Login Successful!

You can close this window and return to the terminal.

+`) + resultChan <- AuthCodeCallbackResult{Code: code, State: state} + }) + + server.Handler = mux + + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Debugf("auth code callback server error: %v", err) + } + }() + + go func() { + select { + case <-ctx.Done(): + case <-time.After(10 * time.Minute): + case <-resultChan: + } + _ = server.Shutdown(context.Background()) + }() + + return redirectURI, resultChan, nil +} + +// generatePKCEForAuthCode generates PKCE code verifier and challenge for authorization code flow. +func generatePKCEForAuthCode() (verifier, challenge string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", fmt.Errorf("failed to generate random bytes: %w", err) + } + verifier = base64.RawURLEncoding.EncodeToString(b) + h := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(h[:]) + return verifier, challenge, nil +} + +// generateStateForAuthCode generates a random state parameter. +func generateStateForAuthCode() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// CreateTokenWithAuthCode exchanges authorization code for tokens. +func (c *SSOOIDCClient) CreateTokenWithAuthCode(ctx context.Context, clientID, clientSecret, code, codeVerifier, redirectURI string) (*CreateTokenResponse, error) { + payload := map[string]string{ + "clientId": clientID, + "clientSecret": clientSecret, + "code": code, + "codeVerifier": codeVerifier, + "redirectUri": redirectURI, + "grantType": "authorization_code", + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", kiroUserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + log.Debugf("create token with auth code failed (status %d): %s", resp.StatusCode, string(respBody)) + return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode) + } + + var result CreateTokenResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + + return &result, nil +} + +// LoginWithBuilderIDAuthCode performs the authorization code flow for AWS Builder ID. +// This provides a better UX than device code flow as it uses automatic browser callback. +func (c *SSOOIDCClient) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) { + fmt.Println("\n╔══════════════════════════════════════════════════════════╗") + fmt.Println("║ Kiro Authentication (AWS Builder ID - Auth Code) ║") + fmt.Println("╚══════════════════════════════════════════════════════════╝") + + // Step 1: Generate PKCE and state + codeVerifier, codeChallenge, err := generatePKCEForAuthCode() + if err != nil { + return nil, fmt.Errorf("failed to generate PKCE: %w", err) + } + + state, err := generateStateForAuthCode() + if err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + + // Step 2: Start callback server + fmt.Println("\nStarting callback server...") + redirectURI, resultChan, err := c.startAuthCodeCallbackServer(ctx, state) + if err != nil { + return nil, fmt.Errorf("failed to start callback server: %w", err) + } + log.Debugf("Callback server started, redirect URI: %s", redirectURI) + + // Step 3: Register client with auth code grant type + fmt.Println("Registering client...") + regResp, err := c.RegisterClientForAuthCode(ctx, redirectURI) + if err != nil { + return nil, fmt.Errorf("failed to register client: %w", err) + } + log.Debugf("Client registered: %s", regResp.ClientID) + + // Step 4: Build authorization URL + scopes := "codewhisperer:completions,codewhisperer:analysis,codewhisperer:conversations" + authURL := fmt.Sprintf("%s/authorize?response_type=code&client_id=%s&redirect_uri=%s&scopes=%s&state=%s&code_challenge=%s&code_challenge_method=S256", + ssoOIDCEndpoint, + regResp.ClientID, + redirectURI, + scopes, + state, + codeChallenge, + ) + + // Step 5: Open browser + fmt.Println("\n════════════════════════════════════════════════════════════") + fmt.Println(" Opening browser for authentication...") + fmt.Println("════════════════════════════════════════════════════════════") + fmt.Printf("\n URL: %s\n\n", authURL) + + // Set incognito mode + if c.cfg != nil { + browser.SetIncognitoMode(c.cfg.IncognitoBrowser) + } else { + browser.SetIncognitoMode(true) + } + + if err := browser.OpenURL(authURL); err != nil { + log.Warnf("Could not open browser automatically: %v", err) + fmt.Println(" ⚠ Could not open browser automatically.") + fmt.Println(" Please open the URL above in your browser manually.") + } else { + fmt.Println(" (Browser opened automatically)") + } + + fmt.Println("\n Waiting for authorization callback...") + + // Step 6: Wait for callback + select { + case <-ctx.Done(): + browser.CloseBrowser() + return nil, ctx.Err() + case <-time.After(10 * time.Minute): + browser.CloseBrowser() + return nil, fmt.Errorf("authorization timed out") + case result := <-resultChan: + if result.Error != "" { + browser.CloseBrowser() + return nil, fmt.Errorf("authorization failed: %s", result.Error) + } + + fmt.Println("\n✓ Authorization received!") + + // Close browser + if err := browser.CloseBrowser(); err != nil { + log.Debugf("Failed to close browser: %v", err) + } + + // Step 7: Exchange code for tokens + fmt.Println("Exchanging code for tokens...") + tokenResp, err := c.CreateTokenWithAuthCode(ctx, regResp.ClientID, regResp.ClientSecret, result.Code, codeVerifier, redirectURI) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for tokens: %w", err) + } + + fmt.Println("\n✓ Authentication successful!") + + // Step 8: Get profile ARN + fmt.Println("Fetching profile information...") + profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken) + + // Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing) + email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken) + if email != "" { + fmt.Printf(" Logged in as: %s\n", email) + } + + expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + + return &KiroTokenData{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ProfileArn: profileArn, + ExpiresAt: expiresAt.Format(time.RFC3339), + AuthMethod: "builder-id", + Provider: "AWS", + ClientID: regResp.ClientID, + ClientSecret: regResp.ClientSecret, + Email: email, + }, nil + } +} diff --git a/internal/cmd/kiro_login.go b/internal/cmd/kiro_login.go index 5fc3b9eb1..74d09686f 100644 --- a/internal/cmd/kiro_login.go +++ b/internal/cmd/kiro_login.go @@ -116,6 +116,54 @@ func DoKiroAWSLogin(cfg *config.Config, options *LoginOptions) { fmt.Println("Kiro AWS authentication successful!") } +// DoKiroAWSAuthCodeLogin triggers Kiro authentication with AWS Builder ID using authorization code flow. +// This provides a better UX than device code flow as it uses automatic browser callback. +// +// Parameters: +// - cfg: The application configuration +// - options: Login options including prompts +func DoKiroAWSAuthCodeLogin(cfg *config.Config, options *LoginOptions) { + if options == nil { + options = &LoginOptions{} + } + + // Note: Kiro defaults to incognito mode for multi-account support. + // Users can override with --no-incognito if they want to use existing browser sessions. + + manager := newAuthManager() + + // Use KiroAuthenticator with AWS Builder ID login (authorization code flow) + authenticator := sdkAuth.NewKiroAuthenticator() + record, err := authenticator.LoginWithAuthCode(context.Background(), cfg, &sdkAuth.LoginOptions{ + NoBrowser: options.NoBrowser, + Metadata: map[string]string{}, + Prompt: options.Prompt, + }) + if err != nil { + log.Errorf("Kiro AWS authentication (auth code) failed: %v", err) + fmt.Println("\nTroubleshooting:") + fmt.Println("1. Make sure you have an AWS Builder ID") + fmt.Println("2. Complete the authorization in the browser") + fmt.Println("3. If callback fails, try: --kiro-aws-login (device code flow)") + return + } + + // Save the auth record + savedPath, err := manager.SaveAuth(record, cfg) + if err != nil { + log.Errorf("Failed to save auth: %v", err) + return + } + + if savedPath != "" { + fmt.Printf("Authentication saved to %s\n", savedPath) + } + if record != nil && record.Label != "" { + fmt.Printf("Authenticated as %s\n", record.Label) + } + fmt.Println("Kiro AWS authentication successful!") +} + // DoKiroImport imports Kiro token from Kiro IDE's token file. // This is useful for users who have already logged in via Kiro IDE // and want to use the same credentials in CLI Proxy API. diff --git a/internal/runtime/executor/kiro_executor.go b/internal/runtime/executor/kiro_executor.go index e346b744b..1da7f25ba 100644 --- a/internal/runtime/executor/kiro_executor.go +++ b/internal/runtime/executor/kiro_executor.go @@ -1293,17 +1293,66 @@ func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.Ki log.Debugf("kiro: parseEventStream found stopReason in messageStopEvent: %s", stopReason) } - case "messageMetadataEvent": - // Handle message metadata events which may contain token counts - if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok { + case "messageMetadataEvent", "metadataEvent": + // Handle message metadata events which contain token counts + // Official format: { tokenUsage: { outputTokens, totalTokens, uncachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens, contextUsagePercentage } } + var metadata map[string]interface{} + if m, ok := event["messageMetadataEvent"].(map[string]interface{}); ok { + metadata = m + } else if m, ok := event["metadataEvent"].(map[string]interface{}); ok { + metadata = m + } else { + metadata = event // event itself might be the metadata + } + + // Check for nested tokenUsage object (official format) + if tokenUsage, ok := metadata["tokenUsage"].(map[string]interface{}); ok { + // outputTokens - precise output token count + if outputTokens, ok := tokenUsage["outputTokens"].(float64); ok { + usageInfo.OutputTokens = int64(outputTokens) + log.Infof("kiro: parseEventStream found precise outputTokens in tokenUsage: %d", usageInfo.OutputTokens) + } + // totalTokens - precise total token count + if totalTokens, ok := tokenUsage["totalTokens"].(float64); ok { + usageInfo.TotalTokens = int64(totalTokens) + log.Infof("kiro: parseEventStream found precise totalTokens in tokenUsage: %d", usageInfo.TotalTokens) + } + // uncachedInputTokens - input tokens not from cache + if uncachedInputTokens, ok := tokenUsage["uncachedInputTokens"].(float64); ok { + usageInfo.InputTokens = int64(uncachedInputTokens) + log.Infof("kiro: parseEventStream found uncachedInputTokens in tokenUsage: %d", usageInfo.InputTokens) + } + // cacheReadInputTokens - tokens read from cache + if cacheReadTokens, ok := tokenUsage["cacheReadInputTokens"].(float64); ok { + // Add to input tokens if we have uncached tokens, otherwise use as input + if usageInfo.InputTokens > 0 { + usageInfo.InputTokens += int64(cacheReadTokens) + } else { + usageInfo.InputTokens = int64(cacheReadTokens) + } + log.Debugf("kiro: parseEventStream found cacheReadInputTokens in tokenUsage: %d", int64(cacheReadTokens)) + } + // contextUsagePercentage - can be used as fallback for input token estimation + if ctxPct, ok := tokenUsage["contextUsagePercentage"].(float64); ok { + upstreamContextPercentage = ctxPct + log.Debugf("kiro: parseEventStream found contextUsagePercentage in tokenUsage: %.2f%%", ctxPct) + } + } + + // Fallback: check for direct fields in metadata (legacy format) + if usageInfo.InputTokens == 0 { if inputTokens, ok := metadata["inputTokens"].(float64); ok { usageInfo.InputTokens = int64(inputTokens) log.Debugf("kiro: parseEventStream found inputTokens in messageMetadataEvent: %d", usageInfo.InputTokens) } + } + if usageInfo.OutputTokens == 0 { if outputTokens, ok := metadata["outputTokens"].(float64); ok { usageInfo.OutputTokens = int64(outputTokens) log.Debugf("kiro: parseEventStream found outputTokens in messageMetadataEvent: %d", usageInfo.OutputTokens) } + } + if usageInfo.TotalTokens == 0 { if totalTokens, ok := metadata["totalTokens"].(float64); ok { usageInfo.TotalTokens = int64(totalTokens) log.Debugf("kiro: parseEventStream found totalTokens in messageMetadataEvent: %d", usageInfo.TotalTokens) @@ -1356,6 +1405,78 @@ func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.Ki usageInfo.InputTokens, usageInfo.OutputTokens) } + case "meteringEvent": + // Handle metering events from Kiro API (usage billing information) + // Official format: { unit: string, unitPlural: string, usage: number } + if metering, ok := event["meteringEvent"].(map[string]interface{}); ok { + unit := "" + if u, ok := metering["unit"].(string); ok { + unit = u + } + usageVal := 0.0 + if u, ok := metering["usage"].(float64); ok { + usageVal = u + } + log.Infof("kiro: parseEventStream received meteringEvent: usage=%.2f %s", usageVal, unit) + // Store metering info for potential billing/statistics purposes + // Note: This is separate from token counts - it's AWS billing units + } else { + // Try direct fields + unit := "" + if u, ok := event["unit"].(string); ok { + unit = u + } + usageVal := 0.0 + if u, ok := event["usage"].(float64); ok { + usageVal = u + } + if unit != "" || usageVal > 0 { + log.Infof("kiro: parseEventStream received meteringEvent (direct): usage=%.2f %s", usageVal, unit) + } + } + + case "error", "exception", "internalServerException", "invalidStateEvent": + // Handle error events from Kiro API stream + errMsg := "" + errType := eventType + + // Try to extract error message from various formats + if msg, ok := event["message"].(string); ok { + errMsg = msg + } else if errObj, ok := event[eventType].(map[string]interface{}); ok { + if msg, ok := errObj["message"].(string); ok { + errMsg = msg + } + if t, ok := errObj["type"].(string); ok { + errType = t + } + } else if errObj, ok := event["error"].(map[string]interface{}); ok { + if msg, ok := errObj["message"].(string); ok { + errMsg = msg + } + if t, ok := errObj["type"].(string); ok { + errType = t + } + } + + // Check for specific error reasons + if reason, ok := event["reason"].(string); ok { + errMsg = fmt.Sprintf("%s (reason: %s)", errMsg, reason) + } + + log.Errorf("kiro: parseEventStream received error event: type=%s, message=%s", errType, errMsg) + + // For invalidStateEvent, we may want to continue processing other events + if eventType == "invalidStateEvent" { + log.Warnf("kiro: invalidStateEvent received, continuing stream processing") + continue + } + + // For other errors, return the error + if errMsg != "" { + return "", nil, usageInfo, stopReason, fmt.Errorf("kiro API error (%s): %s", errType, errMsg) + } + default: // Check for contextUsagePercentage in any event if ctxPct, ok := event["contextUsagePercentage"].(float64); ok { @@ -1693,30 +1814,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out // IMPORTANT: This must persist across all TranslateStream calls var translatorParam any - // Thinking mode state tracking - based on amq2api implementation - // Tracks whether we're inside a block and handles partial tags - inThinkBlock := false - pendingStartTagChars := 0 // Number of chars that might be start of - pendingEndTagChars := 0 // Number of chars that might be start of - isThinkingBlockOpen := false // Track if thinking content block is open + // Thinking mode state tracking - tag-based parsing for tags in content + inThinkBlock := false // Whether we're currently inside a block + isThinkingBlockOpen := false // Track if thinking content block SSE event is open thinkingBlockIndex := -1 // Index of the thinking content block - var accumulatedThinkingContent strings.Builder // Accumulate thinking content for signature generation - - // Code block state tracking for heuristic thinking tag parsing - // When inside a markdown code block, tags should NOT be parsed - // This prevents false positives when the model outputs code examples containing these tags - inCodeBlock := false - codeFenceType := "" // Track which fence type opened the block ("```" or "~~~") - - // Inline code state tracking - when inside backticks, don't parse thinking tags - // This handles cases like `` being discussed in text - inInlineCode := false + var accumulatedThinkingContent strings.Builder // Accumulate thinking content for token counting - // Track if we've seen any non-whitespace content before a thinking tag - // Real thinking blocks from Kiro always start at the very beginning of the response - // If we see content before , subsequent tags are likely discussion text - hasSeenNonThinkingContent := false - thinkingBlockCompleted := false // Track if we've already completed a thinking block + // Buffer for handling partial tag matches at chunk boundaries + var pendingContent strings.Builder // Buffer content that might be part of a tag // Pre-calculate input tokens from request if possible // Kiro uses Claude format, so try Claude format first, then OpenAI format, then fallback @@ -1820,57 +1925,10 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out currentToolUse = nil } - // Flush any pending tag characters at EOF - // These are partial tag prefixes that were held back waiting for more data - // Since no more data is coming, output them as regular text - var pendingText string - if pendingStartTagChars > 0 { - pendingText = kirocommon.ThinkingStartTag[:pendingStartTagChars] - log.Debugf("kiro: flushing pending start tag chars at EOF: %q", pendingText) - pendingStartTagChars = 0 - } - if pendingEndTagChars > 0 { - pendingText += kirocommon.ThinkingEndTag[:pendingEndTagChars] - log.Debugf("kiro: flushing pending end tag chars at EOF: %q", pendingText) - pendingEndTagChars = 0 - } - - // Output pending text if any - if pendingText != "" { - // If we're in a thinking block, output as thinking content - if inThinkBlock && isThinkingBlockOpen { - thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(pendingText, thinkingBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - // Accumulate thinking content for signature generation - accumulatedThinkingContent.WriteString(pendingText) - } else { - // Output as regular text - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - - claudeEvent := kiroclaude.BuildClaudeStreamEvent(pendingText, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - } + // DISABLED: Tag-based pending character flushing + // This code block was used for tag-based thinking detection which has been + // replaced by reasoningContentEvent handling. No pending tag chars to flush. + // Original code preserved in git history. break } @@ -1954,6 +2012,76 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out log.Debugf("kiro: streamToChannel found stopReason in messageStopEvent: %s", upstreamStopReason) } + case "meteringEvent": + // Handle metering events from Kiro API (usage billing information) + // Official format: { unit: string, unitPlural: string, usage: number } + if metering, ok := event["meteringEvent"].(map[string]interface{}); ok { + unit := "" + if u, ok := metering["unit"].(string); ok { + unit = u + } + usageVal := 0.0 + if u, ok := metering["usage"].(float64); ok { + usageVal = u + } + upstreamCreditUsage = usageVal + hasUpstreamUsage = true + log.Infof("kiro: streamToChannel received meteringEvent: usage=%.4f %s", usageVal, unit) + } else { + // Try direct fields (event is meteringEvent itself) + if unit, ok := event["unit"].(string); ok { + if usage, ok := event["usage"].(float64); ok { + upstreamCreditUsage = usage + hasUpstreamUsage = true + log.Infof("kiro: streamToChannel received meteringEvent (direct): usage=%.4f %s", usage, unit) + } + } + } + + case "error", "exception", "internalServerException": + // Handle error events from Kiro API stream + errMsg := "" + errType := eventType + + // Try to extract error message from various formats + if msg, ok := event["message"].(string); ok { + errMsg = msg + } else if errObj, ok := event[eventType].(map[string]interface{}); ok { + if msg, ok := errObj["message"].(string); ok { + errMsg = msg + } + if t, ok := errObj["type"].(string); ok { + errType = t + } + } else if errObj, ok := event["error"].(map[string]interface{}); ok { + if msg, ok := errObj["message"].(string); ok { + errMsg = msg + } + } + + log.Errorf("kiro: streamToChannel received error event: type=%s, message=%s", errType, errMsg) + + // Send error to the stream and exit + if errMsg != "" { + out <- cliproxyexecutor.StreamChunk{ + Err: fmt.Errorf("kiro API error (%s): %s", errType, errMsg), + } + return + } + + case "invalidStateEvent": + // Handle invalid state events - log and continue (non-fatal) + errMsg := "" + if msg, ok := event["message"].(string); ok { + errMsg = msg + } else if stateEvent, ok := event["invalidStateEvent"].(map[string]interface{}); ok { + if msg, ok := stateEvent["message"].(string); ok { + errMsg = msg + } + } + log.Warnf("kiro: streamToChannel received invalidStateEvent: %s, continuing", errMsg) + continue + default: // Check for upstream usage events from Kiro API // Format: {"unit":"credit","unitPlural":"credits","usage":1.458} @@ -2108,268 +2236,24 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out lastUsageUpdateLen = accumulatedContent.Len() lastUsageUpdateTime = time.Now() - } - - // Process content with thinking tag detection - based on amq2api implementation - // This handles and tags that may span across chunks - remaining := contentDelta - - // If we have pending start tag chars from previous chunk, prepend them - if pendingStartTagChars > 0 { - remaining = kirocommon.ThinkingStartTag[:pendingStartTagChars] + remaining - pendingStartTagChars = 0 - } - - // If we have pending end tag chars from previous chunk, prepend them - if pendingEndTagChars > 0 { - remaining = kirocommon.ThinkingEndTag[:pendingEndTagChars] + remaining - pendingEndTagChars = 0 - } - - for len(remaining) > 0 { - // CRITICAL FIX: Only parse tags when thinking mode was enabled in the request. - // When thinking is NOT enabled, tags in responses should be treated as - // regular text content, not as thinking blocks. This prevents normal text content - // from being incorrectly parsed as thinking when the model outputs tags - // without the user requesting thinking mode. - if !thinkingEnabled { - // Thinking not enabled - emit all content as regular text without parsing tags - if remaining != "" { - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - - claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - break // Exit the for loop - all content processed as text } - // HEURISTIC FIX: Track code block and inline code state to avoid parsing tags - // inside code contexts. When the model outputs code examples containing these tags, - // they should be treated as text. - if !inThinkBlock { - // Check for inline code backticks first (higher priority than code fences) - // This handles cases like `` being discussed in text - backtickIdx := strings.Index(remaining, kirocommon.InlineCodeMarker) - thinkingIdx := strings.Index(remaining, kirocommon.ThinkingStartTag) - - // If backtick comes before thinking tag, handle inline code - if backtickIdx >= 0 && (thinkingIdx < 0 || backtickIdx < thinkingIdx) { - if inInlineCode { - // Closing backtick - emit content up to and including backtick, exit inline code - textToEmit := remaining[:backtickIdx+1] - if textToEmit != "" { - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - remaining = remaining[backtickIdx+1:] - inInlineCode = false - continue - } else { - // Opening backtick - emit content before backtick, enter inline code - textToEmit := remaining[:backtickIdx+1] - if textToEmit != "" { - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - remaining = remaining[backtickIdx+1:] - inInlineCode = true - continue - } - } - - // If inside inline code, emit all content as text (don't parse thinking tags) - if inInlineCode { - if remaining != "" { - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - break // Exit loop - remaining content is inside inline code - } - - // Check for code fence markers (``` or ~~~) to toggle code block state - fenceIdx := strings.Index(remaining, kirocommon.CodeFenceMarker) - altFenceIdx := strings.Index(remaining, kirocommon.AltCodeFenceMarker) - - // Find the earliest fence marker - earliestFenceIdx := -1 - earliestFenceType := "" - if fenceIdx >= 0 && (altFenceIdx < 0 || fenceIdx < altFenceIdx) { - earliestFenceIdx = fenceIdx - earliestFenceType = kirocommon.CodeFenceMarker - } else if altFenceIdx >= 0 { - earliestFenceIdx = altFenceIdx - earliestFenceType = kirocommon.AltCodeFenceMarker - } - - if earliestFenceIdx >= 0 { - // Check if this fence comes before any thinking tag - thinkingIdx := strings.Index(remaining, kirocommon.ThinkingStartTag) - if inCodeBlock { - // Inside code block - check if this fence closes it - if earliestFenceType == codeFenceType { - // This fence closes the code block - // Emit content up to and including the fence as text - textToEmit := remaining[:earliestFenceIdx+len(earliestFenceType)] - if textToEmit != "" { - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - remaining = remaining[earliestFenceIdx+len(earliestFenceType):] - inCodeBlock = false - codeFenceType = "" - log.Debugf("kiro: exited code block") - continue - } - } else if thinkingIdx < 0 || earliestFenceIdx < thinkingIdx { - // Not in code block, and fence comes before thinking tag (or no thinking tag) - // Emit content up to and including the fence as text, then enter code block - textToEmit := remaining[:earliestFenceIdx+len(earliestFenceType)] - if textToEmit != "" { - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - remaining = remaining[earliestFenceIdx+len(earliestFenceType):] - inCodeBlock = true - codeFenceType = earliestFenceType - log.Debugf("kiro: entered code block with fence: %s", earliestFenceType) - continue - } - } - - // If inside code block, emit all content as text (don't parse thinking tags) - if inCodeBlock { - if remaining != "" { - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - break // Exit loop - all remaining content is inside code block - } - } + // TAG-BASED THINKING PARSING: Parse tags from content + // Combine pending content with new content for processing + pendingContent.WriteString(contentDelta) + processContent := pendingContent.String() + pendingContent.Reset() + // Process content looking for thinking tags + for len(processContent) > 0 { if inThinkBlock { - // Inside thinking block - look for end tag - // CRITICAL FIX: Skip tags that are not the real end tag - // This prevents false positives when thinking content discusses these tags - // Pass current code block/inline code state for accurate detection - endIdx := findRealThinkingEndTag(remaining, inCodeBlock, inInlineCode) - + // We're inside a thinking block, look for + endIdx := strings.Index(processContent, kirocommon.ThinkingEndTag) if endIdx >= 0 { - // Found end tag - emit any content before end tag, then close block - thinkContent := remaining[:endIdx] - if thinkContent != "" { - // TRUE STREAMING: Emit thinking content immediately - // Start thinking block if not open + // Found end tag - emit thinking content before the tag + thinkingText := processContent[:endIdx] + if thinkingText != "" { + // Ensure thinking block is open if !isThinkingBlockOpen { contentBlockIndex++ thinkingBlockIndex = contentBlockIndex @@ -2382,22 +2266,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } } - - // Send thinking delta immediately - thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkContent, thinkingBlockIndex) + // Send thinking delta + thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam) for _, chunk := range sseData { if chunk != "" { out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} } } - // Accumulate thinking content for signature generation - accumulatedThinkingContent.WriteString(thinkContent) + accumulatedThinkingContent.WriteString(thinkingText) } - - // Note: Partial tag handling is done via pendingEndTagChars - // When the next chunk arrives, the partial tag will be reconstructed - // Close thinking block if isThinkingBlockOpen { blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex) @@ -2408,84 +2286,68 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } isThinkingBlockOpen = false - accumulatedThinkingContent.Reset() // Reset for potential next thinking block } - inThinkBlock = false - thinkingBlockCompleted = true // Mark that we've completed a thinking block - remaining = remaining[endIdx+len(kirocommon.ThinkingEndTag):] - log.Debugf("kiro: exited thinking block, subsequent tags will be treated as text") + processContent = processContent[endIdx+len(kirocommon.ThinkingEndTag):] + log.Debugf("kiro: closed thinking block, remaining content: %d chars", len(processContent)) } else { - // No end tag found - TRUE STREAMING: emit content immediately - // Only save potential partial tag length for next iteration - pendingEnd := kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingEndTag) - - // Calculate content to emit immediately (excluding potential partial tag) - var contentToEmit string - if pendingEnd > 0 { - contentToEmit = remaining[:len(remaining)-pendingEnd] - // Save partial tag length for next iteration (will be reconstructed from thinkingEndTag) - pendingEndTagChars = pendingEnd - } else { - contentToEmit = remaining + // No end tag found - check for partial match at end + partialMatch := false + for i := 1; i < len(kirocommon.ThinkingEndTag) && i <= len(processContent); i++ { + if strings.HasSuffix(processContent, kirocommon.ThinkingEndTag[:i]) { + // Possible partial tag at end, buffer it + pendingContent.WriteString(processContent[len(processContent)-i:]) + processContent = processContent[:len(processContent)-i] + partialMatch = true + break + } } - - // TRUE STREAMING: Emit thinking content immediately - if contentToEmit != "" { - // Start thinking block if not open - if !isThinkingBlockOpen { - contentBlockIndex++ - thinkingBlockIndex = contentBlockIndex - isThinkingBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) + if !partialMatch || len(processContent) > 0 { + // Emit all as thinking content + if processContent != "" { + if !isThinkingBlockOpen { + contentBlockIndex++ + thinkingBlockIndex = contentBlockIndex + isThinkingBlockOpen = true + blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "") + sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) + for _, chunk := range sseData { + if chunk != "" { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} + } + } + } + thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(processContent, thinkingBlockIndex) + sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam) for _, chunk := range sseData { if chunk != "" { out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} } } + accumulatedThinkingContent.WriteString(processContent) } - - // Send thinking delta immediately - TRUE STREAMING! - thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(contentToEmit, thinkingBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - // Accumulate thinking content for signature generation - accumulatedThinkingContent.WriteString(contentToEmit) } - - remaining = "" + processContent = "" } } else { - // Outside thinking block - look for start tag - // CRITICAL FIX: Only parse tags at the very beginning of the response - // or if we haven't completed a thinking block yet. - // After a thinking block is completed, subsequent tags are likely - // discussion text (e.g., "Kiro returns `` tags") and should NOT be parsed. - startIdx := -1 - if !thinkingBlockCompleted && !hasSeenNonThinkingContent { - startIdx = strings.Index(remaining, kirocommon.ThinkingStartTag) - // If there's non-whitespace content before the tag, it's not a real thinking block - if startIdx > 0 { - textBefore := remaining[:startIdx] - if strings.TrimSpace(textBefore) != "" { - // There's real content before the tag - this is discussion text, not thinking - hasSeenNonThinkingContent = true - startIdx = -1 - log.Debugf("kiro: found tag after non-whitespace content, treating as text") - } - } - } + // Not in thinking block, look for + startIdx := strings.Index(processContent, kirocommon.ThinkingStartTag) if startIdx >= 0 { - // Found start tag - emit text before it and switch to thinking mode - textBefore := remaining[:startIdx] + // Found start tag - emit text content before the tag + textBefore := processContent[:startIdx] if textBefore != "" { - // Only whitespace before thinking tag is allowed - // Start text content block if needed + // Close thinking block if open + if isThinkingBlockOpen { + blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex) + sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam) + for _, chunk := range sseData { + if chunk != "" { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} + } + } + isThinkingBlockOpen = false + } + // Ensure text block is open if !isTextBlockOpen { contentBlockIndex++ isTextBlockOpen = true @@ -2497,7 +2359,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } } - + // Send text delta claudeEvent := kiroclaude.BuildClaudeStreamEvent(textBefore, contentBlockIndex) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) for _, chunk := range sseData { @@ -2506,8 +2368,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } } - - // Close text block before starting thinking block + // Close text block before entering thinking if isTextBlockOpen { blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam) @@ -2518,56 +2379,24 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } isTextBlockOpen = false } - inThinkBlock = true - remaining = remaining[startIdx+len(kirocommon.ThinkingStartTag):] + processContent = processContent[startIdx+len(kirocommon.ThinkingStartTag):] log.Debugf("kiro: entered thinking block") } else { - // No start tag found - check for partial start tag at buffer end - // Only check for partial tags if we haven't completed a thinking block yet - pendingStart := 0 - if !thinkingBlockCompleted && !hasSeenNonThinkingContent { - pendingStart = kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingStartTag) - } - if pendingStart > 0 { - // Emit text except potential partial tag - textToEmit := remaining[:len(remaining)-pendingStart] - if textToEmit != "" { - // Mark that we've seen non-thinking content - if strings.TrimSpace(textToEmit) != "" { - hasSeenNonThinkingContent = true - } - // Start text content block if needed - if !isTextBlockOpen { - contentBlockIndex++ - isTextBlockOpen = true - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "") - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } - } - - claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex) - sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) - for _, chunk := range sseData { - if chunk != "" { - out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} - } - } + // No start tag found - check for partial match at end + partialMatch := false + for i := 1; i < len(kirocommon.ThinkingStartTag) && i <= len(processContent); i++ { + if strings.HasSuffix(processContent, kirocommon.ThinkingStartTag[:i]) { + // Possible partial tag at end, buffer it + pendingContent.WriteString(processContent[len(processContent)-i:]) + processContent = processContent[:len(processContent)-i] + partialMatch = true + break } - pendingStartTagChars = pendingStart - remaining = "" - } else { - // No partial tag - emit all as text - if remaining != "" { - // Mark that we've seen non-thinking content - if strings.TrimSpace(remaining) != "" { - hasSeenNonThinkingContent = true - } - // Start text content block if needed + } + if !partialMatch || len(processContent) > 0 { + // Emit all as text content + if processContent != "" { if !isTextBlockOpen { contentBlockIndex++ isTextBlockOpen = true @@ -2579,8 +2408,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } } - - claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex) + claudeEvent := kiroclaude.BuildClaudeStreamEvent(processContent, contentBlockIndex) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) for _, chunk := range sseData { if chunk != "" { @@ -2588,11 +2416,11 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } } - remaining = "" } + processContent = "" } } - } + } } // Handle tool uses in response (with deduplication) @@ -2658,6 +2486,80 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } + case "reasoningContentEvent": + // Handle official reasoningContentEvent from Kiro API + // This replaces tag-based thinking detection with the proper event type + // Official format: { text: string, signature?: string, redactedContent?: base64 } + var thinkingText string + var signature string + + if re, ok := event["reasoningContentEvent"].(map[string]interface{}); ok { + if text, ok := re["text"].(string); ok { + thinkingText = text + } + if sig, ok := re["signature"].(string); ok { + signature = sig + if len(sig) > 20 { + log.Debugf("kiro: reasoningContentEvent has signature: %s...", sig[:20]) + } else { + log.Debugf("kiro: reasoningContentEvent has signature: %s", sig) + } + } + } else { + // Try direct fields + if text, ok := event["text"].(string); ok { + thinkingText = text + } + if sig, ok := event["signature"].(string); ok { + signature = sig + } + } + + if thinkingText != "" { + // Close text block if open before starting thinking block + if isTextBlockOpen && contentBlockIndex >= 0 { + blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex) + sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam) + for _, chunk := range sseData { + if chunk != "" { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} + } + } + isTextBlockOpen = false + } + + // Start thinking block if not already open + if !isThinkingBlockOpen { + contentBlockIndex++ + thinkingBlockIndex = contentBlockIndex + isThinkingBlockOpen = true + blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "") + sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) + for _, chunk := range sseData { + if chunk != "" { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} + } + } + } + + // Send thinking content + thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex) + sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam) + for _, chunk := range sseData { + if chunk != "" { + out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} + } + } + + // Accumulate for token counting + accumulatedThinkingContent.WriteString(thinkingText) + log.Debugf("kiro: received reasoningContentEvent, text length: %d, has signature: %v", len(thinkingText), signature != "") + } + + // Note: We don't close the thinking block here - it will be closed when we see + // the next assistantResponseEvent or at the end of the stream + _ = signature // Signature can be used for verification if needed + case "toolUseEvent": // Handle dedicated tool use events with input buffering completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs) @@ -2721,17 +2623,71 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out totalUsage.OutputTokens = int64(outputTokens) } - case "messageMetadataEvent": - // Handle message metadata events which may contain token counts - if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok { + case "messageMetadataEvent", "metadataEvent": + // Handle message metadata events which contain token counts + // Official format: { tokenUsage: { outputTokens, totalTokens, uncachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens, contextUsagePercentage } } + var metadata map[string]interface{} + if m, ok := event["messageMetadataEvent"].(map[string]interface{}); ok { + metadata = m + } else if m, ok := event["metadataEvent"].(map[string]interface{}); ok { + metadata = m + } else { + metadata = event // event itself might be the metadata + } + + // Check for nested tokenUsage object (official format) + if tokenUsage, ok := metadata["tokenUsage"].(map[string]interface{}); ok { + // outputTokens - precise output token count + if outputTokens, ok := tokenUsage["outputTokens"].(float64); ok { + totalUsage.OutputTokens = int64(outputTokens) + hasUpstreamUsage = true + log.Infof("kiro: streamToChannel found precise outputTokens in tokenUsage: %d", totalUsage.OutputTokens) + } + // totalTokens - precise total token count + if totalTokens, ok := tokenUsage["totalTokens"].(float64); ok { + totalUsage.TotalTokens = int64(totalTokens) + log.Infof("kiro: streamToChannel found precise totalTokens in tokenUsage: %d", totalUsage.TotalTokens) + } + // uncachedInputTokens - input tokens not from cache + if uncachedInputTokens, ok := tokenUsage["uncachedInputTokens"].(float64); ok { + totalUsage.InputTokens = int64(uncachedInputTokens) + hasUpstreamUsage = true + log.Infof("kiro: streamToChannel found uncachedInputTokens in tokenUsage: %d", totalUsage.InputTokens) + } + // cacheReadInputTokens - tokens read from cache + if cacheReadTokens, ok := tokenUsage["cacheReadInputTokens"].(float64); ok { + // Add to input tokens if we have uncached tokens, otherwise use as input + if totalUsage.InputTokens > 0 { + totalUsage.InputTokens += int64(cacheReadTokens) + } else { + totalUsage.InputTokens = int64(cacheReadTokens) + } + hasUpstreamUsage = true + log.Debugf("kiro: streamToChannel found cacheReadInputTokens in tokenUsage: %d", int64(cacheReadTokens)) + } + // contextUsagePercentage - can be used as fallback for input token estimation + if ctxPct, ok := tokenUsage["contextUsagePercentage"].(float64); ok { + upstreamContextPercentage = ctxPct + log.Debugf("kiro: streamToChannel found contextUsagePercentage in tokenUsage: %.2f%%", ctxPct) + } + } + + // Fallback: check for direct fields in metadata (legacy format) + if totalUsage.InputTokens == 0 { if inputTokens, ok := metadata["inputTokens"].(float64); ok { totalUsage.InputTokens = int64(inputTokens) + hasUpstreamUsage = true log.Debugf("kiro: streamToChannel found inputTokens in messageMetadataEvent: %d", totalUsage.InputTokens) } + } + if totalUsage.OutputTokens == 0 { if outputTokens, ok := metadata["outputTokens"].(float64); ok { totalUsage.OutputTokens = int64(outputTokens) + hasUpstreamUsage = true log.Debugf("kiro: streamToChannel found outputTokens in messageMetadataEvent: %d", totalUsage.OutputTokens) } + } + if totalUsage.TotalTokens == 0 { if totalTokens, ok := metadata["totalTokens"].(float64); ok { totalUsage.TotalTokens = int64(totalTokens) log.Debugf("kiro: streamToChannel found totalTokens in messageMetadataEvent: %d", totalUsage.TotalTokens) diff --git a/internal/translator/kiro/claude/kiro_claude_request.go b/internal/translator/kiro/claude/kiro_claude_request.go index e3e333d12..402591e77 100644 --- a/internal/translator/kiro/claude/kiro_claude_request.go +++ b/internal/translator/kiro/claude/kiro_claude_request.go @@ -222,20 +222,19 @@ func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isA kiroTools := convertClaudeToolsToKiro(tools) // Thinking mode implementation: - // Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled - // by injecting and tags into the system prompt. - // We use a fixed max_thinking_length value since Kiro handles the actual budget internally. + // Kiro API supports official thinking/reasoning mode via tag. + // When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent + // rather than inline tags in assistantResponseEvent. + // We use a high max_thinking_length to allow extensive reasoning. if thinkingEnabled { - thinkingHint := `interleaved -200000 - -IMPORTANT: You MUST use ... tags to show your reasoning process before providing your final response. Think step by step inside the thinking tags.` + thinkingHint := `enabled +200000` if systemPrompt != "" { systemPrompt = thinkingHint + "\n\n" + systemPrompt } else { systemPrompt = thinkingHint } - log.Infof("kiro: injected thinking prompt, has_tools: %v", len(kiroTools) > 0) + log.Infof("kiro: injected thinking prompt (official mode), has_tools: %v", len(kiroTools) > 0) } // Process messages and build history diff --git a/internal/translator/kiro/openai/kiro_openai_request.go b/internal/translator/kiro/openai/kiro_openai_request.go index e4f3e7679..f58b50cfd 100644 --- a/internal/translator/kiro/openai/kiro_openai_request.go +++ b/internal/translator/kiro/openai/kiro_openai_request.go @@ -231,20 +231,19 @@ func BuildKiroPayloadFromOpenAI(openaiBody []byte, modelID, profileArn, origin s kiroTools := convertOpenAIToolsToKiro(tools) // Thinking mode implementation: - // Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled - // by injecting and tags into the system prompt. - // We use a fixed max_thinking_length value since Kiro handles the actual budget internally. + // Kiro API supports official thinking/reasoning mode via tag. + // When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent + // rather than inline tags in assistantResponseEvent. + // We use a high max_thinking_length to allow extensive reasoning. if thinkingEnabled { - thinkingHint := `interleaved -200000 - -IMPORTANT: You MUST use ... tags to show your reasoning process before providing your final response. Think step by step inside the thinking tags.` + thinkingHint := `enabled +200000` if systemPrompt != "" { systemPrompt = thinkingHint + "\n\n" + systemPrompt } else { systemPrompt = thinkingHint } - log.Debugf("kiro-openai: injected thinking prompt") + log.Debugf("kiro-openai: injected thinking prompt (official mode)") } // Process messages and build history diff --git a/sdk/auth/kiro.go b/sdk/auth/kiro.go index 1eed4b94a..b937152d8 100644 --- a/sdk/auth/kiro.go +++ b/sdk/auth/kiro.go @@ -117,6 +117,71 @@ func (a *KiroAuthenticator) Login(ctx context.Context, cfg *config.Config, opts return record, nil } +// LoginWithAuthCode performs OAuth login for Kiro with AWS Builder ID using authorization code flow. +// This provides a better UX than device code flow as it uses automatic browser callback. +func (a *KiroAuthenticator) LoginWithAuthCode(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) { + if cfg == nil { + return nil, fmt.Errorf("kiro auth: configuration is required") + } + + oauth := kiroauth.NewKiroOAuth(cfg) + + // Use AWS Builder ID authorization code flow + tokenData, err := oauth.LoginWithBuilderIDAuthCode(ctx) + if err != nil { + return nil, fmt.Errorf("login failed: %w", err) + } + + // Parse expires_at + expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt) + if err != nil { + expiresAt = time.Now().Add(1 * time.Hour) + } + + // Extract identifier for file naming + idPart := extractKiroIdentifier(tokenData.Email, tokenData.ProfileArn) + + now := time.Now() + fileName := fmt.Sprintf("kiro-aws-%s.json", idPart) + + record := &coreauth.Auth{ + ID: fileName, + Provider: "kiro", + FileName: fileName, + Label: "kiro-aws", + Status: coreauth.StatusActive, + CreatedAt: now, + UpdatedAt: now, + Metadata: map[string]any{ + "type": "kiro", + "access_token": tokenData.AccessToken, + "refresh_token": tokenData.RefreshToken, + "profile_arn": tokenData.ProfileArn, + "expires_at": tokenData.ExpiresAt, + "auth_method": tokenData.AuthMethod, + "provider": tokenData.Provider, + "client_id": tokenData.ClientID, + "client_secret": tokenData.ClientSecret, + "email": tokenData.Email, + }, + Attributes: map[string]string{ + "profile_arn": tokenData.ProfileArn, + "source": "aws-builder-id-authcode", + "email": tokenData.Email, + }, + // NextRefreshAfter is aligned with RefreshLead (5min) + NextRefreshAfter: expiresAt.Add(-5 * time.Minute), + } + + if tokenData.Email != "" { + fmt.Printf("\n✓ Kiro authentication completed successfully! (Account: %s)\n", tokenData.Email) + } else { + fmt.Println("\n✓ Kiro authentication completed successfully!") + } + + return record, nil +} + // LoginWithGoogle performs OAuth login for Kiro with Google. // This uses a custom protocol handler (kiro://) to receive the callback. func (a *KiroAuthenticator) LoginWithGoogle(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {