Merge pull request #42 from Ravens2121/master

feat(kiro): 新增授权码登录流程,优化邮箱获取与官方 Thinking 模式解析 预支持
This commit is contained in:
Luis Pater
2025-12-18 08:36:27 +08:00
committed by GitHub
9 changed files with 1126 additions and 484 deletions
+6
View File
@@ -78,6 +78,7 @@ func main() {
var kiroLogin bool var kiroLogin bool
var kiroGoogleLogin bool var kiroGoogleLogin bool
var kiroAWSLogin bool var kiroAWSLogin bool
var kiroAWSAuthCode bool
var kiroImport bool var kiroImport bool
var githubCopilotLogin bool var githubCopilotLogin bool
var projectID string var projectID string
@@ -101,6 +102,7 @@ func main() {
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth") 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(&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(&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(&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.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
@@ -513,6 +515,10 @@ func main() {
// Users can explicitly override with --no-incognito // Users can explicitly override with --no-incognito
setKiroIncognitoMode(cfg, useIncognito, noIncognito) setKiroIncognitoMode(cfg, useIncognito, noIncognito)
cmd.DoKiroAWSLogin(cfg, options) 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 { } else if kiroImport {
cmd.DoKiroImport(cfg, options) cmd.DoKiroImport(cfg, options)
} else { } else {
+166
View File
@@ -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)
}
+7
View File
@@ -163,6 +163,13 @@ func (o *KiroOAuth) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, err
return ssoClient.LoginWithBuilderID(ctx) 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. // exchangeCodeForToken exchanges the authorization code for tokens.
func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) { func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) {
payload := map[string]string{ payload := map[string]string{
+403 -7
View File
@@ -3,9 +3,14 @@ package kiro
import ( import (
"context" "context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io" "io"
"net"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -25,6 +30,13 @@ const (
// Polling interval // Polling interval
pollInterval = 5 * time.Second 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. // SSOOIDCClient handles AWS SSO OIDC authentication.
@@ -73,13 +85,11 @@ type CreateTokenResponse struct {
// RegisterClient registers a new OIDC client with AWS. // RegisterClient registers a new OIDC client with AWS.
func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResponse, error) { 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{}{ payload := map[string]interface{}{
"clientName": clientName, "clientName": "Kiro IDE",
"clientType": "public", "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) body, err := json.Marshal(payload)
@@ -92,6 +102,7 @@ func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResp
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -135,6 +146,7 @@ func (c *SSOOIDCClient) StartDeviceAuthorization(ctx context.Context, clientID,
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -179,6 +191,7 @@ func (c *SSOOIDCClient) CreateToken(ctx context.Context, clientID, clientSecret,
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -240,6 +253,7 @@ func (c *SSOOIDCClient) RefreshToken(ctx context.Context, clientID, clientSecret
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
@@ -370,8 +384,8 @@ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData,
fmt.Println("Fetching profile information...") fmt.Println("Fetching profile information...")
profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken) profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
// Extract email from JWT access token // Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
email := ExtractEmailFromJWT(tokenResp.AccessToken) email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
if email != "" { if email != "" {
fmt.Printf(" Logged in as: %s\n", 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") 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. // fetchProfileArn retrieves the profile ARN from CodeWhisperer API.
// This is needed for file naming since AWS SSO OIDC doesn't return profile info. // 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 { func (c *SSOOIDCClient) fetchProfileArn(ctx context.Context, accessToken string) string {
@@ -525,3 +601,323 @@ func (c *SSOOIDCClient) tryListCustomizations(ctx context.Context, accessToken s
return "" 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, `<!DOCTYPE html>
<html><head><title>Login Failed</title></head>
<body><h1>Login Failed</h1><p>Error: %s</p><p>You can close this window.</p></body></html>`, html.EscapeString(errParam))
resultChan <- AuthCodeCallbackResult{Error: errParam}
return
}
if state != expectedState {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `<!DOCTYPE html>
<html><head><title>Login Failed</title></head>
<body><h1>Login Failed</h1><p>Invalid state parameter</p><p>You can close this window.</p></body></html>`)
resultChan <- AuthCodeCallbackResult{Error: "state mismatch"}
return
}
fmt.Fprint(w, `<!DOCTYPE html>
<html><head><title>Login Successful</title></head>
<body><h1>Login Successful!</h1><p>You can close this window and return to the terminal.</p>
<script>window.close();</script></body></html>`)
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
}
}
+48
View File
@@ -116,6 +116,54 @@ func DoKiroAWSLogin(cfg *config.Config, options *LoginOptions) {
fmt.Println("Kiro AWS authentication successful!") 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. // DoKiroImport imports Kiro token from Kiro IDE's token file.
// This is useful for users who have already logged in via Kiro IDE // This is useful for users who have already logged in via Kiro IDE
// and want to use the same credentials in CLI Proxy API. // and want to use the same credentials in CLI Proxy API.
+403 -447
View File
@@ -1293,17 +1293,66 @@ func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.Ki
log.Debugf("kiro: parseEventStream found stopReason in messageStopEvent: %s", stopReason) log.Debugf("kiro: parseEventStream found stopReason in messageStopEvent: %s", stopReason)
} }
case "messageMetadataEvent": case "messageMetadataEvent", "metadataEvent":
// Handle message metadata events which may contain token counts // Handle message metadata events which contain token counts
if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok { // 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 { if inputTokens, ok := metadata["inputTokens"].(float64); ok {
usageInfo.InputTokens = int64(inputTokens) usageInfo.InputTokens = int64(inputTokens)
log.Debugf("kiro: parseEventStream found inputTokens in messageMetadataEvent: %d", usageInfo.InputTokens) log.Debugf("kiro: parseEventStream found inputTokens in messageMetadataEvent: %d", usageInfo.InputTokens)
} }
}
if usageInfo.OutputTokens == 0 {
if outputTokens, ok := metadata["outputTokens"].(float64); ok { if outputTokens, ok := metadata["outputTokens"].(float64); ok {
usageInfo.OutputTokens = int64(outputTokens) usageInfo.OutputTokens = int64(outputTokens)
log.Debugf("kiro: parseEventStream found outputTokens in messageMetadataEvent: %d", usageInfo.OutputTokens) log.Debugf("kiro: parseEventStream found outputTokens in messageMetadataEvent: %d", usageInfo.OutputTokens)
} }
}
if usageInfo.TotalTokens == 0 {
if totalTokens, ok := metadata["totalTokens"].(float64); ok { if totalTokens, ok := metadata["totalTokens"].(float64); ok {
usageInfo.TotalTokens = int64(totalTokens) usageInfo.TotalTokens = int64(totalTokens)
log.Debugf("kiro: parseEventStream found totalTokens in messageMetadataEvent: %d", usageInfo.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) 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: default:
// Check for contextUsagePercentage in any event // Check for contextUsagePercentage in any event
if ctxPct, ok := event["contextUsagePercentage"].(float64); ok { 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 // IMPORTANT: This must persist across all TranslateStream calls
var translatorParam any var translatorParam any
// Thinking mode state tracking - based on amq2api implementation // Thinking mode state tracking - tag-based parsing for <thinking> tags in content
// Tracks whether we're inside a <thinking> block and handles partial tags inThinkBlock := false // Whether we're currently inside a <thinking> block
inThinkBlock := false isThinkingBlockOpen := false // Track if thinking content block SSE event is open
pendingStartTagChars := 0 // Number of chars that might be start of <thinking>
pendingEndTagChars := 0 // Number of chars that might be start of </thinking>
isThinkingBlockOpen := false // Track if thinking content block is open
thinkingBlockIndex := -1 // Index of the thinking content block thinkingBlockIndex := -1 // Index of the thinking content block
var accumulatedThinkingContent strings.Builder // Accumulate thinking content for signature generation var accumulatedThinkingContent strings.Builder // Accumulate thinking content for token counting
// Code block state tracking for heuristic thinking tag parsing // Buffer for handling partial tag matches at chunk boundaries
// When inside a markdown code block, <thinking> tags should NOT be parsed var pendingContent strings.Builder // Buffer content that might be part of a tag
// 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 `<thinking>` being discussed in text
inInlineCode := false
// 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 <thinking>, subsequent <thinking> tags are likely discussion text
hasSeenNonThinkingContent := false
thinkingBlockCompleted := false // Track if we've already completed a thinking block
// Pre-calculate input tokens from request if possible // Pre-calculate input tokens from request if possible
// Kiro uses Claude format, so try Claude format first, then OpenAI format, then fallback // 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 currentToolUse = nil
} }
// Flush any pending tag characters at EOF // DISABLED: Tag-based pending character flushing
// These are partial tag prefixes that were held back waiting for more data // This code block was used for tag-based thinking detection which has been
// Since no more data is coming, output them as regular text // replaced by reasoningContentEvent handling. No pending tag chars to flush.
var pendingText string // Original code preserved in git history.
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")}
}
}
}
}
break 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) 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: default:
// Check for upstream usage events from Kiro API // Check for upstream usage events from Kiro API
// Format: {"unit":"credit","unitPlural":"credits","usage":1.458} // Format: {"unit":"credit","unitPlural":"credits","usage":1.458}
@@ -2110,266 +2238,22 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
lastUsageUpdateTime = time.Now() lastUsageUpdateTime = time.Now()
} }
// Process content with thinking tag detection - based on amq2api implementation // TAG-BASED THINKING PARSING: Parse <thinking> tags from content
// This handles <thinking> and </thinking> tags that may span across chunks // Combine pending content with new content for processing
remaining := contentDelta pendingContent.WriteString(contentDelta)
processContent := pendingContent.String()
// If we have pending start tag chars from previous chunk, prepend them pendingContent.Reset()
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 <thinking> tags when thinking mode was enabled in the request.
// When thinking is NOT enabled, <thinking> 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 <thinking> 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 <thinking> 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 `<thinking>` 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
}
}
// Process content looking for thinking tags
for len(processContent) > 0 {
if inThinkBlock { if inThinkBlock {
// Inside thinking block - look for </thinking> end tag // We're inside a thinking block, look for </thinking>
// CRITICAL FIX: Skip </thinking> tags that are not the real end tag endIdx := strings.Index(processContent, kirocommon.ThinkingEndTag)
// 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)
if endIdx >= 0 { if endIdx >= 0 {
// Found end tag - emit any content before end tag, then close block // Found end tag - emit thinking content before the tag
thinkContent := remaining[:endIdx] thinkingText := processContent[:endIdx]
if thinkContent != "" { if thinkingText != "" {
// TRUE STREAMING: Emit thinking content immediately // Ensure thinking block is open
// Start thinking block if not open
if !isThinkingBlockOpen { if !isThinkingBlockOpen {
contentBlockIndex++ contentBlockIndex++
thinkingBlockIndex = contentBlockIndex thinkingBlockIndex = contentBlockIndex
@@ -2382,22 +2266,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
} }
} }
} }
// Send thinking delta
// Send thinking delta immediately thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkContent, thinkingBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
for _, chunk := range sseData { for _, chunk := range sseData {
if chunk != "" { if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
} }
} }
// Accumulate thinking content for signature generation accumulatedThinkingContent.WriteString(thinkingText)
accumulatedThinkingContent.WriteString(thinkContent)
} }
// Note: Partial tag handling is done via pendingEndTagChars
// When the next chunk arrives, the partial tag will be reconstructed
// Close thinking block // Close thinking block
if isThinkingBlockOpen { if isThinkingBlockOpen {
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex) blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
@@ -2408,31 +2286,25 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
} }
} }
isThinkingBlockOpen = false isThinkingBlockOpen = false
accumulatedThinkingContent.Reset() // Reset for potential next thinking block
} }
inThinkBlock = false inThinkBlock = false
thinkingBlockCompleted = true // Mark that we've completed a thinking block processContent = processContent[endIdx+len(kirocommon.ThinkingEndTag):]
remaining = remaining[endIdx+len(kirocommon.ThinkingEndTag):] log.Debugf("kiro: closed thinking block, remaining content: %d chars", len(processContent))
log.Debugf("kiro: exited thinking block, subsequent <thinking> tags will be treated as text")
} else { } else {
// No end tag found - TRUE STREAMING: emit content immediately // No end tag found - check for partial match at end
// Only save potential partial tag length for next iteration partialMatch := false
pendingEnd := kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingEndTag) for i := 1; i < len(kirocommon.ThinkingEndTag) && i <= len(processContent); i++ {
if strings.HasSuffix(processContent, kirocommon.ThinkingEndTag[:i]) {
// Calculate content to emit immediately (excluding potential partial tag) // Possible partial tag at end, buffer it
var contentToEmit string pendingContent.WriteString(processContent[len(processContent)-i:])
if pendingEnd > 0 { processContent = processContent[:len(processContent)-i]
contentToEmit = remaining[:len(remaining)-pendingEnd] partialMatch = true
// Save partial tag length for next iteration (will be reconstructed from thinkingEndTag) break
pendingEndTagChars = pendingEnd
} else {
contentToEmit = remaining
} }
}
// TRUE STREAMING: Emit thinking content immediately if !partialMatch || len(processContent) > 0 {
if contentToEmit != "" { // Emit all as thinking content
// Start thinking block if not open if processContent != "" {
if !isThinkingBlockOpen { if !isThinkingBlockOpen {
contentBlockIndex++ contentBlockIndex++
thinkingBlockIndex = contentBlockIndex thinkingBlockIndex = contentBlockIndex
@@ -2445,47 +2317,37 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
} }
} }
} }
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(processContent, thinkingBlockIndex)
// Send thinking delta immediately - TRUE STREAMING!
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(contentToEmit, thinkingBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
for _, chunk := range sseData { for _, chunk := range sseData {
if chunk != "" { if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")} out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
} }
} }
// Accumulate thinking content for signature generation accumulatedThinkingContent.WriteString(processContent)
accumulatedThinkingContent.WriteString(contentToEmit)
} }
}
remaining = "" processContent = ""
} }
} else { } else {
// Outside thinking block - look for <thinking> start tag // Not in thinking block, look for <thinking>
// CRITICAL FIX: Only parse <thinking> tags at the very beginning of the response startIdx := strings.Index(processContent, kirocommon.ThinkingStartTag)
// or if we haven't completed a thinking block yet.
// After a thinking block is completed, subsequent <thinking> tags are likely
// discussion text (e.g., "Kiro returns `<thinking>` 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 <thinking> tag after non-whitespace content, treating as text")
}
}
}
if startIdx >= 0 { if startIdx >= 0 {
// Found start tag - emit text before it and switch to thinking mode // Found start tag - emit text content before the tag
textBefore := remaining[:startIdx] textBefore := processContent[:startIdx]
if textBefore != "" { if textBefore != "" {
// Only whitespace before thinking tag is allowed // Close thinking block if open
// Start text content block if needed 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 { if !isTextBlockOpen {
contentBlockIndex++ contentBlockIndex++
isTextBlockOpen = true 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) claudeEvent := kiroclaude.BuildClaudeStreamEvent(textBefore, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData { for _, chunk := range sseData {
@@ -2506,8 +2368,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
} }
} }
} }
// Close text block before entering thinking
// Close text block before starting thinking block
if isTextBlockOpen { if isTextBlockOpen {
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex) blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
@@ -2518,26 +2379,24 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
} }
isTextBlockOpen = false isTextBlockOpen = false
} }
inThinkBlock = true inThinkBlock = true
remaining = remaining[startIdx+len(kirocommon.ThinkingStartTag):] processContent = processContent[startIdx+len(kirocommon.ThinkingStartTag):]
log.Debugf("kiro: entered thinking block") log.Debugf("kiro: entered thinking block")
} else { } else {
// No start tag found - check for partial start tag at buffer end // No start tag found - check for partial match at end
// Only check for partial tags if we haven't completed a thinking block yet partialMatch := false
pendingStart := 0 for i := 1; i < len(kirocommon.ThinkingStartTag) && i <= len(processContent); i++ {
if !thinkingBlockCompleted && !hasSeenNonThinkingContent { if strings.HasSuffix(processContent, kirocommon.ThinkingStartTag[:i]) {
pendingStart = kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingStartTag) // Possible partial tag at end, buffer it
pendingContent.WriteString(processContent[len(processContent)-i:])
processContent = processContent[:len(processContent)-i]
partialMatch = true
break
} }
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 !partialMatch || len(processContent) > 0 {
// Emit all as text content
if processContent != "" {
if !isTextBlockOpen { if !isTextBlockOpen {
contentBlockIndex++ contentBlockIndex++
isTextBlockOpen = true isTextBlockOpen = true
@@ -2549,8 +2408,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
} }
} }
} }
claudeEvent := kiroclaude.BuildClaudeStreamEvent(processContent, contentBlockIndex)
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData { for _, chunk := range sseData {
if chunk != "" { if chunk != "" {
@@ -2558,38 +2416,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
} }
} }
} }
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 !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")}
}
}
}
remaining = ""
} }
processContent = ""
} }
} }
} }
@@ -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": case "toolUseEvent":
// Handle dedicated tool use events with input buffering // Handle dedicated tool use events with input buffering
completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs) 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) totalUsage.OutputTokens = int64(outputTokens)
} }
case "messageMetadataEvent": case "messageMetadataEvent", "metadataEvent":
// Handle message metadata events which may contain token counts // Handle message metadata events which contain token counts
if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok { // 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 { if inputTokens, ok := metadata["inputTokens"].(float64); ok {
totalUsage.InputTokens = int64(inputTokens) totalUsage.InputTokens = int64(inputTokens)
hasUpstreamUsage = true
log.Debugf("kiro: streamToChannel found inputTokens in messageMetadataEvent: %d", totalUsage.InputTokens) log.Debugf("kiro: streamToChannel found inputTokens in messageMetadataEvent: %d", totalUsage.InputTokens)
} }
}
if totalUsage.OutputTokens == 0 {
if outputTokens, ok := metadata["outputTokens"].(float64); ok { if outputTokens, ok := metadata["outputTokens"].(float64); ok {
totalUsage.OutputTokens = int64(outputTokens) totalUsage.OutputTokens = int64(outputTokens)
hasUpstreamUsage = true
log.Debugf("kiro: streamToChannel found outputTokens in messageMetadataEvent: %d", totalUsage.OutputTokens) log.Debugf("kiro: streamToChannel found outputTokens in messageMetadataEvent: %d", totalUsage.OutputTokens)
} }
}
if totalUsage.TotalTokens == 0 {
if totalTokens, ok := metadata["totalTokens"].(float64); ok { if totalTokens, ok := metadata["totalTokens"].(float64); ok {
totalUsage.TotalTokens = int64(totalTokens) totalUsage.TotalTokens = int64(totalTokens)
log.Debugf("kiro: streamToChannel found totalTokens in messageMetadataEvent: %d", totalUsage.TotalTokens) log.Debugf("kiro: streamToChannel found totalTokens in messageMetadataEvent: %d", totalUsage.TotalTokens)
@@ -222,20 +222,19 @@ func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isA
kiroTools := convertClaudeToolsToKiro(tools) kiroTools := convertClaudeToolsToKiro(tools)
// Thinking mode implementation: // Thinking mode implementation:
// Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled // Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
// by injecting <thinking_mode> and <max_thinking_length> tags into the system prompt. // When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
// We use a fixed max_thinking_length value since Kiro handles the actual budget internally. // rather than inline <thinking> tags in assistantResponseEvent.
// We use a high max_thinking_length to allow extensive reasoning.
if thinkingEnabled { if thinkingEnabled {
thinkingHint := `<thinking_mode>interleaved</thinking_mode> thinkingHint := `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length> <max_thinking_length>200000</max_thinking_length>`
IMPORTANT: You MUST use <thinking>...</thinking> tags to show your reasoning process before providing your final response. Think step by step inside the thinking tags.`
if systemPrompt != "" { if systemPrompt != "" {
systemPrompt = thinkingHint + "\n\n" + systemPrompt systemPrompt = thinkingHint + "\n\n" + systemPrompt
} else { } else {
systemPrompt = thinkingHint 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 // Process messages and build history
@@ -231,20 +231,19 @@ func BuildKiroPayloadFromOpenAI(openaiBody []byte, modelID, profileArn, origin s
kiroTools := convertOpenAIToolsToKiro(tools) kiroTools := convertOpenAIToolsToKiro(tools)
// Thinking mode implementation: // Thinking mode implementation:
// Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled // Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
// by injecting <thinking_mode> and <max_thinking_length> tags into the system prompt. // When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
// We use a fixed max_thinking_length value since Kiro handles the actual budget internally. // rather than inline <thinking> tags in assistantResponseEvent.
// We use a high max_thinking_length to allow extensive reasoning.
if thinkingEnabled { if thinkingEnabled {
thinkingHint := `<thinking_mode>interleaved</thinking_mode> thinkingHint := `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length> <max_thinking_length>200000</max_thinking_length>`
IMPORTANT: You MUST use <thinking>...</thinking> tags to show your reasoning process before providing your final response. Think step by step inside the thinking tags.`
if systemPrompt != "" { if systemPrompt != "" {
systemPrompt = thinkingHint + "\n\n" + systemPrompt systemPrompt = thinkingHint + "\n\n" + systemPrompt
} else { } else {
systemPrompt = thinkingHint systemPrompt = thinkingHint
} }
log.Debugf("kiro-openai: injected thinking prompt") log.Debugf("kiro-openai: injected thinking prompt (official mode)")
} }
// Process messages and build history // Process messages and build history
+65
View File
@@ -117,6 +117,71 @@ func (a *KiroAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
return record, nil 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. // LoginWithGoogle performs OAuth login for Kiro with Google.
// This uses a custom protocol handler (kiro://) to receive the callback. // 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) { func (a *KiroAuthenticator) LoginWithGoogle(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {