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) {